1
0
mirror of https://github.com/verdaccio/verdaccio.git synced 2024-11-13 03:35:52 +01:00

feat: complete overhaul of web user interface (#4687)

* fix: ui-component updates

* Update all

* Update tests

* Updates

* Updates

* Updates

* Updates

* Updates

* Updates

* Updates

* Updates

* Dark logo

* Add showUplinks parameter

* Fix DependencyBlock links

* Update

* Fix highlight dark

* Update

* Color

* Fix uncaught exception

* changeset

* Fix Install Settingsmenu, tsconfig

* Remove duplicate function (merge issue)

* Fix SideBar test and CodeQL issue
This commit is contained in:
Marc Bernard 2024-07-07 14:12:24 +02:00 committed by GitHub
parent 2ee28c0988
commit 10dd81f473
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
162 changed files with 2657 additions and 1220 deletions

@ -0,0 +1,10 @@
---
'@verdaccio/ui-components': minor
'@verdaccio/ui-theme': patch
'@verdaccio/types': patch
'@verdaccio/middleware': patch
'@verdaccio/config': patch
'@verdaccio/cli': patch
---
feat: complete overhaul of web user interface

@ -18,6 +18,12 @@ storage: ./storage
# https://verdaccio.org/docs/webui # https://verdaccio.org/docs/webui
web: web:
title: Verdaccio title: Verdaccio
# custom colors for header background and font
# primaryColor: "#4b5e40"
# custom logos and favicon
# logo: ./path/to/logo.png
# logoDark: ./path/to/logoDark.png
# favicon: ./path/to/favicon.ico
# comment out to disable gravatar support # comment out to disable gravatar support
# gravatar: false # gravatar: false
# by default packages are ordercer ascendant (asc|desc) # by default packages are ordercer ascendant (asc|desc)
@ -35,6 +41,7 @@ web:
# showSearch: true # showSearch: true
# showRaw: true # showRaw: true
# showDownloadTarball: true # showDownloadTarball: true
# showUplinks: true
# HTML tags injected after manifest <scripts/> # HTML tags injected after manifest <scripts/>
# scriptsBodyAfter: # scriptsBodyAfter:
# - '<script type="text/javascript" src="https://my.company.com/customJS.min.js"></script>' # - '<script type="text/javascript" src="https://my.company.com/customJS.min.js"></script>'

@ -21,6 +21,12 @@ plugins: /verdaccio/plugins
# https://verdaccio.org/docs/webui # https://verdaccio.org/docs/webui
web: web:
title: Verdaccio title: Verdaccio
# custom colors for header background and font
# primaryColor: "#4b5e40"
# custom logos and favicon
# logo: ./path/to/logo.png
# logoDark: ./path/to/logoDark.png
# favicon: ./path/to/favicon.ico
# Comment out to disable gravatar support # Comment out to disable gravatar support
# gravatar: false # gravatar: false
# By default packages are ordered ascendant (asc|desc) # By default packages are ordered ascendant (asc|desc)
@ -38,6 +44,7 @@ web:
# showSearch: true # showSearch: true
# showRaw: true # showRaw: true
# showDownloadTarball: true # showDownloadTarball: true
# showUplinks: true
# HTML tags injected after manifest <scripts/> # HTML tags injected after manifest <scripts/>
# scriptsBodyAfter: # scriptsBodyAfter:
# - '<script type="text/javascript" src="https://my.company.com/customJS.min.js"></script>' # - '<script type="text/javascript" src="https://my.company.com/customJS.min.js"></script>'

@ -83,6 +83,7 @@ export type PackageManagers = 'pnpm' | 'yarn' | 'npm';
export type CommonWebConf = { export type CommonWebConf = {
title?: string; title?: string;
logo?: string; logo?: string;
logoDark?: string;
favicon?: string; favicon?: string;
gravatar?: boolean; gravatar?: boolean;
sort_packages?: string; sort_packages?: string;
@ -98,6 +99,7 @@ export type CommonWebConf = {
showFooter?: boolean; showFooter?: boolean;
showThemeSwitch?: boolean; showThemeSwitch?: boolean;
showDownloadTarball?: boolean; showDownloadTarball?: boolean;
showUplinks?: boolean;
hideDeprecatedVersions?: boolean; hideDeprecatedVersions?: boolean;
primaryColor: string; primaryColor: string;
showRaw?: boolean; showRaw?: boolean;

@ -42,10 +42,11 @@ export function renderWebMiddleware(config, tokenMiddleware, pluginOptions) {
res.sendFile(file, sendFileCallback(next)); res.sendFile(file, sendFileCallback(next));
}); });
function renderLogo(logo: string | undefined): string | undefined {
// check the origin of the logo // check the origin of the logo
if (config?.web?.logo && !isURLhasValidProtocol(config?.web?.logo)) { if (logo && !isURLhasValidProtocol(logo)) {
// URI related to a local file // URI related to a local file
const absoluteLocalFile = path.posix.resolve(config.web.logo); const absoluteLocalFile = path.posix.resolve(logo);
debug('serve local logo %s', absoluteLocalFile); debug('serve local logo %s', absoluteLocalFile);
try { try {
// TODO: replace existsSync by async alternative // TODO: replace existsSync by async alternative
@ -55,22 +56,33 @@ export function renderWebMiddleware(config, tokenMiddleware, pluginOptions) {
) { ) {
// Note: `path.join` will break on Windows, because it transforms `/` to `\` // Note: `path.join` will break on Windows, because it transforms `/` to `\`
// Use POSIX version `path.posix.join` instead. // Use POSIX version `path.posix.join` instead.
config.web.logo = path.posix.join('/-/static/', path.basename(config.web.logo)); logo = path.posix.join('/-/static/', path.basename(logo));
router.get(config.web.logo, function (_req, res, next) { router.get(logo, function (_req, res, next) {
// @ts-ignore // @ts-ignore
debug('serve custom logo web:%s - local:%s', config.web.logo, absoluteLocalFile); debug('serve custom logo web:%s - local:%s', logo, absoluteLocalFile);
res.sendFile(absoluteLocalFile, sendFileCallback(next)); res.sendFile(absoluteLocalFile, sendFileCallback(next));
}); });
debug('enabled custom logo %s', config.web.logo); debug('enabled custom logo %s', logo);
} else { } else {
config.web.logo = undefined; logo = undefined;
debug(`web logo is wrong, path ${absoluteLocalFile} does not exist or is not readable`); debug(`web logo is wrong, path ${absoluteLocalFile} does not exist or is not readable`);
} }
} catch { } catch {
config.web.logo = undefined; logo = undefined;
debug(`web logo is wrong, path ${absoluteLocalFile} does not exist or is not readable`); debug(`web logo is wrong, path ${absoluteLocalFile} does not exist or is not readable`);
} }
} }
return logo;
}
const logo = renderLogo(config?.web?.logo);
if (config?.web?.logo) {
config.web.logo = logo;
}
const logoDark = renderLogo(config?.web?.logoDark);
if (config?.web?.logoDark) {
config.web.logoDark = logoDark;
}
router.get('/-/web/:section/*', function (req, res) { router.get('/-/web/:section/*', function (req, res) {
renderHTML(config, manifest, manifestFiles, req, res); renderHTML(config, manifest, manifestFiles, req, res);

@ -26,16 +26,20 @@ const defaultManifestFiles: Manifest = {
css: [], css: [],
}; };
export function resolveLogo(config: ConfigYaml, requestOptions: RequestOptions) { export function resolveLogo(
if (typeof config?.web?.logo !== 'string') { logo: string | undefined,
url_prefix: string | undefined,
requestOptions: RequestOptions
) {
if (typeof logo !== 'string') {
return ''; return '';
} }
const isLocalFile = config?.web?.logo && !isURLhasValidProtocol(config?.web?.logo); const isLocalFile = logo && !isURLhasValidProtocol(logo);
if (isLocalFile) { if (isLocalFile) {
return `${getPublicUrl(config?.url_prefix, requestOptions)}-/static/${path.basename(config?.web?.logo)}`; return `${getPublicUrl(url_prefix, requestOptions)}-/static/${path.basename(logo)}`;
} else if (isURLhasValidProtocol(config?.web?.logo)) { } else if (isURLhasValidProtocol(logo)) {
return config?.web?.logo; return logo;
} else { } else {
return ''; return '';
} }
@ -61,7 +65,8 @@ export default function renderHTML(
const title = config?.web?.title ?? WEB_TITLE; const title = config?.web?.title ?? WEB_TITLE;
const login = hasLogin(config); const login = hasLogin(config);
const scope = config?.web?.scope ?? ''; const scope = config?.web?.scope ?? '';
const logo = resolveLogo(config, requestOptions); const logo = resolveLogo(config?.web?.logo, config?.url_prefix, requestOptions);
const logoDark = resolveLogo(config?.web?.logoDark, config?.url_prefix, requestOptions);
const pkgManagers = config?.web?.pkgManagers ?? ['yarn', 'pnpm', 'npm']; const pkgManagers = config?.web?.pkgManagers ?? ['yarn', 'pnpm', 'npm'];
const version = res.locals.app_version ?? ''; const version = res.locals.app_version ?? '';
const flags = { const flags = {
@ -81,6 +86,8 @@ export default function renderHTML(
showFooter, showFooter,
showSearch, showSearch,
showDownloadTarball, showDownloadTarball,
showRaw,
showUplinks,
} = Object.assign( } = Object.assign(
{}, {},
{ {
@ -97,6 +104,8 @@ export default function renderHTML(
showFooter, showFooter,
showSearch, showSearch,
showDownloadTarball, showDownloadTarball,
showRaw,
showUplinks,
darkMode, darkMode,
url_prefix, url_prefix,
basename, basename,
@ -104,6 +113,7 @@ export default function renderHTML(
primaryColor, primaryColor,
version, version,
logo, logo,
logoDark,
flags, flags,
login, login,
pkgManagers, pkgManagers,

@ -14,6 +14,7 @@ web:
showRaw: true showRaw: true
primary_color: '#ffffff' primary_color: '#ffffff'
logo: './test/config/dark-logo.png' logo: './test/config/dark-logo.png'
logoDark: './test/config/dark-logo.png'
html_cache: false html_cache: false
url_prefix: /prefix url_prefix: /prefix

@ -79,6 +79,13 @@ describe('test web server', () => {
return loadLogo('file-logo.yaml', '/-/static/dark-logo.png'); return loadLogo('file-logo.yaml', '/-/static/dark-logo.png');
}); });
test('should render dark logo as file', async () => {
const {
window: { __VERDACCIO_BASENAME_UI_OPTIONS },
} = await render('file-logo.yaml');
expect(__VERDACCIO_BASENAME_UI_OPTIONS.logoDark).toMatch('/prefix/-/static/dark-logo.png');
});
test('should not render logo as absolute file is wrong', async () => { test('should not render logo as absolute file is wrong', async () => {
const { const {
window: { __VERDACCIO_BASENAME_UI_OPTIONS }, window: { __VERDACCIO_BASENAME_UI_OPTIONS },

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { renderWithStore, screen } from 'verdaccio-ui/utils/test-react-testing-library'; import { act, renderWithStore, screen } from 'verdaccio-ui/utils/test-react-testing-library';
import { store } from '@verdaccio/ui-components'; import { store } from '@verdaccio/ui-components';
@ -13,17 +13,21 @@ jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600);
/* eslint-disable react/jsx-no-bind*/ /* eslint-disable react/jsx-no-bind*/
describe('<App />', () => { describe('<App />', () => {
describe('footer', () => { describe('footer', () => {
test('should display the Header component', () => { test('should display the Header component', async () => {
await act(() => {
renderWithStore(<App />, store); renderWithStore(<App />, store);
});
expect(screen.getByTestId('footer')).toBeInTheDocument(); expect(screen.getByTestId('footer')).toBeInTheDocument();
}); });
test('should not display the Header component', () => { test('should not display the Header component', async () => {
// @ts-ignore // @ts-ignore
window.__VERDACCIO_BASENAME_UI_OPTIONS = { window.__VERDACCIO_BASENAME_UI_OPTIONS = {
showFooter: false, showFooter: false,
}; };
await act(() => {
renderWithStore(<App />, store); renderWithStore(<App />, store);
});
expect(screen.queryByTestId('footer')).toBeFalsy(); expect(screen.queryByTestId('footer')).toBeFalsy();
}); });
}); });

@ -6,7 +6,8 @@
"visit-home-page": "Visit homepage", "visit-home-page": "Visit homepage",
"open-an-issue": "Open an issue", "open-an-issue": "Open an issue",
"download-tarball": "Download tarball", "download-tarball": "Download tarball",
"raw": "Raw Manifest" "raw": "View manifest",
"raw-title": "Manifest of {{package}}"
}, },
"dialog": { "dialog": {
"registry-info": { "registry-info": {
@ -59,7 +60,7 @@
"hide-deprecated": "All deprecated versions are hidden by global configuration" "hide-deprecated": "All deprecated versions are hidden by global configuration"
}, },
"package": { "package": {
"published-on": "Published on {{time}}", "published-on": "Published {{time}}",
"version": "v{{version}}", "version": "v{{version}}",
"visit-home-page": "Visit homepage", "visit-home-page": "Visit homepage",
"homepage": "Homepage", "homepage": "Homepage",
@ -84,7 +85,7 @@
}, },
"form-validation": { "form-validation": {
"required-field": "This field is required", "required-field": "This field is required",
"required-min-length": "This field required the min length of {{length}}", "required-min-length": "This field required with a minimum length of {{length}}",
"unable-to-sign-in": "Unable to sign in", "unable-to-sign-in": "Unable to sign in",
"username-or-password-cant-be-empty": "Username or password can't be empty!" "username-or-password-cant-be-empty": "Username or password can't be empty!"
}, },
@ -105,6 +106,7 @@
}, },
"installation": { "installation": {
"title": "Installation", "title": "Installation",
"latest": "latest version",
"global": "global package", "global": "global package",
"yarnModern": "yarn modern syntax" "yarnModern": "yarn modern syntax"
}, },
@ -115,10 +117,13 @@
"title": "Author" "title": "Author"
}, },
"distribution": { "distribution": {
"title": "Latest Distribution", "title": "Distribution",
"license": "License", "license": "License",
"size": "Size", "size": "Size",
"file-count": "file count" "file-count": "File Count"
},
"keywords": {
"title": "Keywords"
}, },
"maintainers": { "maintainers": {
"title": "Maintainers" "title": "Maintainers"

@ -60,7 +60,7 @@
"react/jsx-curly-brace-presence": ["warn", { "props": "ignore", "children": "ignore" }], "react/jsx-curly-brace-presence": ["warn", { "props": "ignore", "children": "ignore" }],
"react/jsx-pascal-case": ["error"], "react/jsx-pascal-case": ["error"],
"react/jsx-props-no-multi-spaces": ["error"], "react/jsx-props-no-multi-spaces": ["error"],
"react/jsx-sort-default-props": ["error"], "react/sort-default-props": ["error"],
"react/jsx-sort-props": ["error"], "react/jsx-sort-props": ["error"],
"react/no-string-refs": ["error"], "react/no-string-refs": ["error"],
"react/no-danger-with-children": ["error"], "react/no-danger-with-children": ["error"],

@ -0,0 +1,3 @@
module.exports = () =>
// eslint-disable-next-line max-len
'<h1 id="storybook-cli">Storybook CLI</h1>\n<p>This is a wrapper for <a href="https://www.npmjs.com/package/@storybook/cli">https://www.npmjs.com/package/@storybook/cli</a></p>';

@ -1,2 +0,0 @@
<h1 id="storybook-cli">Storybook CLI</h1>
<p>This is a wrapper for <a href="https://www.npmjs.com/package/@storybook/cli">https://www.npmjs.com/package/@storybook/cli</a></p>

@ -1,3 +1,4 @@
import fs from 'fs';
import { rest } from 'msw'; import { rest } from 'msw';
const packagesPayload = require('./api/home-packages.json'); const packagesPayload = require('./api/home-packages.json');
@ -21,24 +22,20 @@ export const handlers = [
}), }),
rest.get('http://localhost:9000/-/verdaccio/data/package/readme/storybook', (req, res, ctx) => { rest.get('http://localhost:9000/-/verdaccio/data/package/readme/storybook', (req, res, ctx) => {
return res( return res(ctx.text(require('./api/storybook-readme')()));
ctx.text(`<h1 id="storybook-cli">Storybook CLI MSW.js</h1>
<p>This is a wrapper for <a href="https://www.npmjs.com/package/@storybook/cli">https://www.npmjs.com/package/@storybook/cli</a></p>
`)
);
}), }),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/jquery', (req, res, ctx) => { rest.get('http://localhost:9000/-/verdaccio/data/sidebar/jquery', (req, res, ctx) => {
return res(ctx.json(require('./api/jquery-sidebar.json'))); return res(ctx.json(require('./api/jquery-sidebar.json')));
}), }),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/JSONStream', (req, res, ctx) => { rest.get('http://localhost:9000/-/verdaccio/data/sidebar/JSONStream', (req, res, ctx) => {
return res(ctx.status(401)); return res(ctx.status(401)); // unauthorized
}), }),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/semver', (req, res, ctx) => { rest.get('http://localhost:9000/-/verdaccio/data/sidebar/semver', (req, res, ctx) => {
return res(ctx.status(500)); return res(ctx.status(500)); // internal server error
}), }),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/kleur', (req, res, ctx) => { rest.get('http://localhost:9000/-/verdaccio/data/sidebar/kleur', (req, res, ctx) => {
return res(ctx.status(404)); return res(ctx.status(404)); // not found
}), }),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/glob', (req, res, ctx) => { rest.get('http://localhost:9000/-/verdaccio/data/sidebar/glob', (req, res, ctx) => {
return res(ctx.json(require('./api/glob-sidebar.json'))); return res(ctx.json(require('./api/glob-sidebar.json')));
@ -55,6 +52,14 @@ export const handlers = [
rest.get('http://localhost:9000/-/verdaccio/data/package/readme/jquery', (req, res, ctx) => { rest.get('http://localhost:9000/-/verdaccio/data/package/readme/jquery', (req, res, ctx) => {
return res(ctx.text(require('./api/jquery-readme')())); return res(ctx.text(require('./api/jquery-readme')()));
}), }),
rest.get('http://localhost:9000/verdaccio/-/verdaccio-1.0.0.tgz', (req, res, ctx) => {
const fileContent = fs.readFileSync('./api/verdaccio-1.0.0.tgz');
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.body(fileContent)
);
}),
rest.post<{ username: string; password: string }, { token: string; username: string }>( rest.post<{ username: string; password: string }, { token: string; username: string }>(
'http://localhost:9000/-/verdaccio/sec/login', 'http://localhost:9000/-/verdaccio/sec/login',

@ -0,0 +1,65 @@
import React from 'react';
import { MemoryRouter } from 'react-router';
import { store } from '../';
import { act, renderWithStore, screen, waitFor } from '../test/test-react-testing-library';
import AppRoute from './AppRoute';
// force the windows to expand to display items
// https://github.com/bvaughn/react-virtualized/issues/493#issuecomment-640084107
jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600);
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600);
function appTest(path: string) {
renderWithStore(
<MemoryRouter initialEntries={[path]}>
<AppRoute />
</MemoryRouter>,
store
);
}
// See jest/server-handlers.ts for test routes
describe('AppRoute', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders Front component for ROOT path', async () => {
act(() => appTest('/'));
await waitFor(() => expect(screen.getByTestId('loading')).toBeInTheDocument());
await waitFor(() => expect(screen.getAllByTestId('package-item-list')).toHaveLength(5));
});
test('renders VersionPage component for PACKAGE path', async () => {
act(() => appTest('/-/web/detail/jquery'));
await waitFor(() => screen.getByTestId('readme-tab'));
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});
test('renders VersionPage component for PACKAGE VERSION path', async () => {
act(() => appTest('/-/web/detail/jquery/v/3.6.3'));
await waitFor(() => screen.getByTestId('readme-tab'));
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});
test('renders Forbidden component for not allowed PACKAGE', async () => {
act(() => appTest('/-/web/detail/JSONstream'));
await waitFor(() => screen.getByTestId('not-found-go-to-home-button'));
});
test('renders NotFound component for missing PACKAGE', async () => {
act(() => appTest('/-/web/detail/kleur'));
await waitFor(() => screen.getByTestId('not-found-go-to-home-button'));
});
test('renders NotFound component for non-existing PACKAGE VERSION', async () => {
act(() => appTest('/-/web/detail/jquery/v/0.9.9'));
await waitFor(() => screen.getByTestId('not-found-go-to-home-button'));
});
test('renders NotFound component for non-matching path', async () => {
act(() => appTest('/oiccadrev'));
await waitFor(() => screen.getByTestId('not-found-go-to-home-button'));
});
});

@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import {
AppConfigurationProvider,
PersistenceSettingProvider,
StyleBaseline,
ThemeProvider,
} from '../';
const AppContainer = () => (
<AppConfigurationProvider>
<ThemeProvider>
<StyleBaseline />
<PersistenceSettingProvider>
<div>{'Theme'}</div>
</PersistenceSettingProvider>
</ThemeProvider>
</AppConfigurationProvider>
);
describe('ThemeProvider', () => {
test('should render with theme', async () => {
render(<AppContainer />);
await screen.findByText('Theme');
});
});

@ -6,11 +6,11 @@ const colors = {
black: '#000', black: '#000',
white: '#fff', white: '#fff',
red: '#d32f2f', red: '#d32f2f',
orange: '#CD4000', orange: '#cd4000',
greySuperLight: '#f5f5f5', greySuperLight: '#f5f5f5',
greyLight: '#d3d3d3', greyLight: '#d3d3d3',
greyLight2: '#908ba1', greyLight2: '#908ba1',
greyLight3: '#f3f4f240', greyLight3: '#f3f4f2',
greyDark: '#a9a9a9', greyDark: '#a9a9a9',
greyDark2: '#586069', greyDark2: '#586069',
greyChateau: '#95989a', greyChateau: '#95989a',
@ -38,7 +38,7 @@ const themeModes = {
...colors, ...colors,
primary: '#ffffff', primary: '#ffffff',
secondary: '#424242', secondary: '#424242',
background: '#1A202C', background: '#1a202c',
}, },
}; };

@ -1,28 +1,22 @@
import React from 'react'; import React from 'react';
import { store } from '../../store/store'; import { store } from '../../store/store';
import { import { cleanup, fireEvent, renderWithStore, screen } from '../../test/test-react-testing-library';
cleanup,
fireEvent,
renderWithStore,
screen,
waitFor,
} from '../../test/test-react-testing-library';
import ActionBar from './ActionBar'; import ActionBar from './ActionBar';
const defaultPackageMeta = { const defaultPackageMeta = {
_uplinks: {}, _uplinks: {},
latest: { latest: {
name: 'verdaccio-ui/local-storage', name: 'verdaccio',
version: '8.0.1-next.1', version: '1.0.0',
dist: { dist: {
fileCount: 0, fileCount: 1,
unpackedSize: 0, unpackedSize: 171,
tarball: 'http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz', tarball: 'http://localhost:9000/verdaccio/-/verdaccio-1.0.0.tgz',
}, },
homepage: 'https://verdaccio.org', homepage: 'https://verdaccio.org',
bugs: { bugs: {
url: 'https://github.com/verdaccio/monorepo/issues', url: 'https://github.com/verdaccio/verdaccio/issues',
}, },
}, },
}; };
@ -39,6 +33,12 @@ describe('<ActionBar /> component', () => {
expect(screen.getByTestId('HomeIcon')).toBeInTheDocument(); expect(screen.getByTestId('HomeIcon')).toBeInTheDocument();
}); });
test('should not render if data is missing', () => {
// @ts-ignore - testing with missing data
renderWithStore(<ActionBar packageMeta={undefined} />, store);
expect(screen.queryByTestId('HomeIcon')).toBeNull();
});
test('when there is no action bar data', () => { test('when there is no action bar data', () => {
const packageMeta = { const packageMeta = {
...defaultPackageMeta, ...defaultPackageMeta,
@ -64,6 +64,14 @@ describe('<ActionBar /> component', () => {
expect(screen.getByLabelText('action-bar-action.download-tarball')).toBeTruthy(); expect(screen.getByLabelText('action-bar-action.download-tarball')).toBeTruthy();
}); });
test('when button to download is disabled', () => {
renderWithStore(
<ActionBar packageMeta={defaultPackageMeta} showDownloadTarball={false} />,
store
);
expect(screen.queryByTestId('download-tarball-btn')).not.toBeInTheDocument();
});
test('when there is a button to raw manifest', () => { test('when there is a button to raw manifest', () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store); renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store);
expect(screen.getByLabelText('action-bar-action.raw')).toBeTruthy(); expect(screen.getByLabelText('action-bar-action.raw')).toBeTruthy();
@ -71,15 +79,28 @@ describe('<ActionBar /> component', () => {
test('when click button to raw manifest open a dialog with viewer', async () => { test('when click button to raw manifest open a dialog with viewer', async () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store); renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store);
expect(screen.queryByTestId('rawViewer--dialog')).toBeFalsy();
fireEvent.click(screen.getByLabelText('action-bar-action.raw')); fireEvent.click(screen.getByLabelText('action-bar-action.raw'));
await waitFor(() => expect(screen.getByTestId('rawViewer--dialog')).toBeInTheDocument()); await screen.findByTestId('rawViewer--dialog');
fireEvent.click(screen.getByTestId('close-raw-viewer'));
await screen.getByLabelText('action-bar-action.raw');
expect(screen.queryByTestId('rawViewer--dialog')).toBeFalsy();
}); });
test('should not display download tarball button', () => { test('should not display download tarball button', () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store); renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={false} />, store);
expect(screen.queryByLabelText('Download tarball')).toBeFalsy(); expect(screen.queryByLabelText('Download tarball')).toBeFalsy();
}); });
test('when click button to download ', async () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={false} />, store);
fireEvent.click(screen.getByTestId('download-tarball-btn'));
await store.getState().loading.models.download;
});
test('should not display show raw button', () => { test('should not display show raw button', () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={false} />, store); renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={false} />, store);
expect(screen.queryByLabelText('action-bar-action.raw')).toBeFalsy(); expect(screen.queryByLabelText('action-bar-action.raw')).toBeFalsy();

@ -41,7 +41,7 @@ const ActionBar: React.FC<Props> = ({ showRaw, showDownloadTarball = true, packa
} }
return ( return (
<Box alignItems="center" display="flex" marginBottom="14px"> <Box alignItems="center" display="flex" sx={{ my: 2 }}>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
{actions.map((action) => ( {actions.map((action) => (
<ActionBarAction key={action.type} {...action} /> <ActionBarAction key={action.type} {...action} />

@ -12,11 +12,14 @@ import { useDispatch, useSelector } from 'react-redux';
import { Theme } from '../../Theme'; import { Theme } from '../../Theme';
import { Dispatch, RootState } from '../../store/store'; import { Dispatch, RootState } from '../../store/store';
import { Link } from '../Link'; import LinkExternal from '../LinkExternal';
export const Fab = styled(FabMUI)<{ theme?: Theme }>(({ theme }) => ({ export const Fab = styled(FabMUI)<{ theme?: Theme }>(({ theme }) => ({
backgroundColor: backgroundColor:
theme?.palette.mode === 'light' ? theme?.palette.primary.main : theme?.palette.cyanBlue, theme?.palette.mode === 'light' ? theme?.palette.primary.main : theme?.palette.cyanBlue,
'&:hover': {
color: theme?.palette.mode === 'light' ? theme?.palette.primary.main : theme?.palette.cyanBlue,
},
color: theme?.palette.white, color: theme?.palette.white,
})); }));
@ -42,21 +45,21 @@ const ActionBarAction: React.FC<ActionBarActionProps> = ({ type, link, action })
case 'VISIT_HOMEPAGE': case 'VISIT_HOMEPAGE':
return ( return (
<Tooltip title={t('action-bar-action.visit-home-page') as string}> <Tooltip title={t('action-bar-action.visit-home-page') as string}>
<Link external={true} to={link} variant="button"> <LinkExternal to={link} variant="button">
<Fab size="small"> <Fab size="small">
<HomeIcon /> <HomeIcon />
</Fab> </Fab>
</Link> </LinkExternal>
</Tooltip> </Tooltip>
); );
case 'OPEN_AN_ISSUE': case 'OPEN_AN_ISSUE':
return ( return (
<Tooltip title={t('action-bar-action.open-an-issue') as string}> <Tooltip title={t('action-bar-action.open-an-issue') as string}>
<Link external={true} to={link} variant="button"> <LinkExternal to={link} variant="button">
<Fab size="small"> <Fab size="small">
<BugReportIcon /> <BugReportIcon />
</Fab> </Fab>
</Link> </LinkExternal>
</Tooltip> </Tooltip>
); );
case 'DOWNLOAD_TARBALL': case 'DOWNLOAD_TARBALL':

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { cleanup, render } from '../../test/test-react-testing-library'; import { cleanup, render, screen } from '../../test/test-react-testing-library';
import { PackageMetaInterface } from '../../types/packageMeta'; import { PackageMetaInterface } from '../../types/packageMeta';
import Authors from './Author'; import Authors from './Author';
@ -66,4 +66,10 @@ describe('<Author /> component', () => {
const wrapper = render(withAuthorComponent(packageMeta)); const wrapper = render(withAuthorComponent(packageMeta));
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
test('should not render if data is missing', () => {
// @ts-ignore - testing with missing data
render(withAuthorComponent(undefined));
expect(screen.queryByText('sidebar.author.title')).toBeNull();
});
}); });

@ -1,35 +1,13 @@
import { Typography } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import List from '@mui/material/List'; import List from '@mui/material/List';
import { useTheme } from '@mui/styles';
import i18next from 'i18next';
import React, { FC } from 'react'; import React, { FC } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { url } from '../../utils'; import Person from '../Person';
import { AuthorListItem, StyledText } from './styles'; import { AuthorListItem, StyledText } from './styles';
export function getAuthorName(authorName?: string): string {
if (!authorName) {
return i18next.t('author-unknown');
}
if (authorName.toLowerCase() === 'anonymous') {
return i18next.t('author-anonymous');
}
return authorName;
}
/**
* @param param0
* @returns
*/
const Author: FC<{ packageMeta }> = ({ packageMeta }) => { const Author: FC<{ packageMeta }> = ({ packageMeta }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
if (!packageMeta) { if (!packageMeta) {
return null; return null;
} }
@ -40,26 +18,10 @@ const Author: FC<{ packageMeta }> = ({ packageMeta }) => {
return null; return null;
} }
const { email, name } = author;
const avatarComponent = (
<Avatar
alt={author.name}
src={author.avatar}
sx={{ width: 40, height: 40, marginRight: theme.spacing(1) }}
/>
);
return ( return (
<List subheader={<StyledText variant={'subtitle1'}>{t('sidebar.author.title')}</StyledText>}> <List subheader={<StyledText variant={'subtitle1'}>{t('sidebar.author.title')}</StyledText>}>
<AuthorListItem> <AuthorListItem sx={{ my: 1 }}>
{!email || !url.isEmail(email) ? ( <Person packageName={packageName} person={author} version={version} withText={true} />
avatarComponent
) : (
<a href={`mailto:${email}?subject=${packageName}@${version}`} target={'_top'}>
{avatarComponent}
</a>
)}
{name && <Typography variant="subtitle2">{getAuthorName(name)}</Typography>}
</AuthorListItem> </AuthorListItem>
</List> </List>
); );

@ -44,6 +44,8 @@ exports[`<Author /> component should render the component in default state 1`] =
padding-bottom: 8px; padding-bottom: 8px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
margin-top: 8px;
margin-bottom: 8px;
padding: 0; padding: 0;
} }
@ -68,6 +70,22 @@ exports[`<Author /> component should render the component in default state 1`] =
} }
.emotion-5 { .emotion-5 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-5:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-6 {
position: relative; position: relative;
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
@ -97,10 +115,11 @@ exports[`<Author /> component should render the component in default state 1`] =
user-select: none; user-select: none;
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-left: 0px;
margin-right: 8px; margin-right: 8px;
} }
.emotion-6 { .emotion-7 {
width: 100%; width: 100%;
height: 100%; height: 100%;
text-align: center; text-align: center;
@ -109,12 +128,13 @@ exports[`<Author /> component should render the component in default state 1`] =
text-indent: 10000px; text-indent: 10000px;
} }
.emotion-7 { .emotion-8 {
margin: 0; margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif; font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.57; line-height: 1.57;
margin-left: 8px;
} }
<body> <body>
@ -131,21 +151,25 @@ exports[`<Author /> component should render the component in default state 1`] =
class="MuiListItem-root MuiListItem-gutters MuiListItem-padding emotion-3 emotion-4" class="MuiListItem-root MuiListItem-gutters MuiListItem-padding emotion-3 emotion-4"
> >
<a <a
href="mailto:verdaccio.user@verdaccio.org?subject=verdaccio@4.0.0" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-5"
target="_top" data-mui-internal-clone-element="true"
data-testid="verdaccio user"
href="mailto:verdaccio.user@verdaccio.org?subject=verdaccio v4.0.0"
rel="noopener noreferrer"
target="_blank"
> >
<div <div
class="MuiAvatar-root MuiAvatar-circular emotion-5" class="MuiAvatar-root MuiAvatar-circular emotion-6"
> >
<img <img
alt="verdaccio user" alt="verdaccio user"
class="MuiAvatar-img emotion-6" class="MuiAvatar-img emotion-7"
src="https://www.gravatar.com/avatar/000000" src="https://www.gravatar.com/avatar/000000"
/> />
</div> </div>
</a> </a>
<h6 <h6
class="MuiTypography-root MuiTypography-subtitle2 emotion-7" class="MuiTypography-root MuiTypography-subtitle2 emotion-8"
> >
verdaccio user verdaccio user
</h6> </h6>
@ -194,6 +218,8 @@ exports[`<Author /> component should render the component in default state 1`] =
padding-bottom: 8px; padding-bottom: 8px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
margin-top: 8px;
margin-bottom: 8px;
padding: 0; padding: 0;
} }
@ -218,6 +244,22 @@ exports[`<Author /> component should render the component in default state 1`] =
} }
.emotion-5 { .emotion-5 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-5:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-6 {
position: relative; position: relative;
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
@ -247,10 +289,11 @@ exports[`<Author /> component should render the component in default state 1`] =
user-select: none; user-select: none;
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-left: 0px;
margin-right: 8px; margin-right: 8px;
} }
.emotion-6 { .emotion-7 {
width: 100%; width: 100%;
height: 100%; height: 100%;
text-align: center; text-align: center;
@ -259,12 +302,13 @@ exports[`<Author /> component should render the component in default state 1`] =
text-indent: 10000px; text-indent: 10000px;
} }
.emotion-7 { .emotion-8 {
margin: 0; margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif; font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.57; line-height: 1.57;
margin-left: 8px;
} }
<div> <div>
@ -280,21 +324,25 @@ exports[`<Author /> component should render the component in default state 1`] =
class="MuiListItem-root MuiListItem-gutters MuiListItem-padding emotion-3 emotion-4" class="MuiListItem-root MuiListItem-gutters MuiListItem-padding emotion-3 emotion-4"
> >
<a <a
href="mailto:verdaccio.user@verdaccio.org?subject=verdaccio@4.0.0" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-5"
target="_top" data-mui-internal-clone-element="true"
data-testid="verdaccio user"
href="mailto:verdaccio.user@verdaccio.org?subject=verdaccio v4.0.0"
rel="noopener noreferrer"
target="_blank"
> >
<div <div
class="MuiAvatar-root MuiAvatar-circular emotion-5" class="MuiAvatar-root MuiAvatar-circular emotion-6"
> >
<img <img
alt="verdaccio user" alt="verdaccio user"
class="MuiAvatar-img emotion-6" class="MuiAvatar-img emotion-7"
src="https://www.gravatar.com/avatar/000000" src="https://www.gravatar.com/avatar/000000"
/> />
</div> </div>
</a> </a>
<h6 <h6
class="MuiTypography-root MuiTypography-subtitle2 emotion-7" class="MuiTypography-root MuiTypography-subtitle2 emotion-8"
> >
verdaccio user verdaccio user
</h6> </h6>
@ -399,6 +447,8 @@ exports[`<Author /> component should render the component when there is no autho
padding-bottom: 8px; padding-bottom: 8px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
margin-top: 8px;
margin-bottom: 8px;
padding: 0; padding: 0;
} }
@ -452,6 +502,7 @@ exports[`<Author /> component should render the component when there is no autho
user-select: none; user-select: none;
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-left: 0px;
margin-right: 8px; margin-right: 8px;
} }
@ -470,6 +521,7 @@ exports[`<Author /> component should render the component when there is no autho
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.57; line-height: 1.57;
margin-left: 8px;
} }
<body> <body>
@ -487,6 +539,8 @@ exports[`<Author /> component should render the component when there is no autho
> >
<div <div
class="MuiAvatar-root MuiAvatar-circular emotion-5" class="MuiAvatar-root MuiAvatar-circular emotion-5"
data-mui-internal-clone-element="true"
data-testid="verdaccio user"
> >
<img <img
alt="verdaccio user" alt="verdaccio user"
@ -544,6 +598,8 @@ exports[`<Author /> component should render the component when there is no autho
padding-bottom: 8px; padding-bottom: 8px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
margin-top: 8px;
margin-bottom: 8px;
padding: 0; padding: 0;
} }
@ -597,6 +653,7 @@ exports[`<Author /> component should render the component when there is no autho
user-select: none; user-select: none;
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-left: 0px;
margin-right: 8px; margin-right: 8px;
} }
@ -615,6 +672,7 @@ exports[`<Author /> component should render the component when there is no autho
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.57; line-height: 1.57;
margin-left: 8px;
} }
<div> <div>
@ -631,6 +689,8 @@ exports[`<Author /> component should render the component when there is no autho
> >
<div <div
class="MuiAvatar-root MuiAvatar-circular emotion-5" class="MuiAvatar-root MuiAvatar-circular emotion-5"
data-mui-internal-clone-element="true"
data-testid="verdaccio user"
> >
<img <img
alt="verdaccio user" alt="verdaccio user"

@ -0,0 +1,23 @@
import React from 'react';
import { fireEvent, render, screen } from '../../test/test-react-testing-library';
import CopyToClipBoard from './CopyToClipBoard';
Object.assign(navigator, {
clipboard: { writeText: jest.fn().mockImplementation(() => Promise.resolve()) },
});
describe('CopyToClipBoard component', () => {
test('should copy text to clipboard', async () => {
const copyThis = 'copy this';
render(
<CopyToClipBoard dataTestId={'copy-component'} text={copyThis} title={`npm i verdaccio`} />
);
expect(screen.getByTestId('copy-component')).toBeInTheDocument();
const copyComponent = await screen.findByTestId('copy-component');
await fireEvent.click(copyComponent);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(copyThis);
});
});

@ -5,17 +5,6 @@ export const copyToClipBoardUtility =
(event: SyntheticEvent<HTMLElement>): void => { (event: SyntheticEvent<HTMLElement>): void => {
event.preventDefault(); event.preventDefault();
const node = document.createElement('div'); // document.execCommand is deprecated
node.innerText = str; navigator.clipboard.writeText(str);
if (document.body) {
document.body.appendChild(node);
const range = document.createRange();
const selection = window.getSelection() as Selection;
range.selectNodeContents(node);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('copy');
document.body.removeChild(node);
}
}; };

@ -1,19 +1,13 @@
import styled from '@emotion/styled';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Theme } from '../../Theme';
import NoItems from '../NoItems'; import NoItems from '../NoItems';
import { DependencyBlock } from './DependencyBlock'; import { DependencyBlock } from './DependencyBlock';
import { hasKeys } from './utits'; import { hasKeys } from './utits';
export const CardWrap = styled(Card)<{ theme?: Theme }>((props) => ({
marginBottom: props.theme.spacing(2),
}));
const Dependencies: React.FC<{ packageMeta: any }> = ({ packageMeta }) => { const Dependencies: React.FC<{ packageMeta: any }> = ({ packageMeta }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -43,7 +37,7 @@ const Dependencies: React.FC<{ packageMeta: any }> = ({ packageMeta }) => {
hasKeys(peerDependencies); hasKeys(peerDependencies);
if (hasDependencies) { if (hasDependencies) {
return ( return (
<CardWrap> <Card sx={{ mb: 2 }}>
<CardContent> <CardContent>
<Box data-testid="dependencies-box" sx={{ m: 2 }}> <Box data-testid="dependencies-box" sx={{ m: 2 }}>
{Object.entries(dependencyMap).map(([dependencyType, dependencies]) => { {Object.entries(dependencyMap).map(([dependencyType, dependencies]) => {
@ -62,11 +56,17 @@ const Dependencies: React.FC<{ packageMeta: any }> = ({ packageMeta }) => {
})} })}
</Box> </Box>
</CardContent> </CardContent>
</CardWrap> </Card>
); );
} }
return <NoItems text={t('dependencies.has-no-dependencies', { package: name })} />; return (
<Card sx={{ mb: 2 }}>
<CardContent>
<NoItems text={t('dependencies.has-no-dependencies', { package: name })} />
</CardContent>
</Card>
);
}; };
export default Dependencies; export default Dependencies;

@ -9,6 +9,7 @@ import { useHistory } from 'react-router-dom';
import { Theme } from '../../Theme'; import { Theme } from '../../Theme';
import { PackageDependencies } from '../../types/packageMeta'; import { PackageDependencies } from '../../types/packageMeta';
import { Route } from '../../utils';
interface DependencyBlockProps { interface DependencyBlockProps {
title: string; title: string;
@ -41,7 +42,7 @@ export const DependencyBlock: React.FC<DependencyBlockProps> = ({ title, depende
const deps = Object.entries(dependencies); const deps = Object.entries(dependencies);
function handleClick(name: string): void { function handleClick(name: string): void {
history.push(`/-/web/detail/${name}`); history.push(`${Route.DETAIL}${name}`);
} }
function labelText(title: string, name: string, version: string): string { function labelText(title: string, name: string, version: string): string {

@ -0,0 +1,4 @@
export enum DeveloperType {
CONTRIBUTORS = 'contributors',
MAINTAINERS = 'maintainers',
}

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { cleanup, fireEvent, render } from '../../test/test-react-testing-library'; import { cleanup, fireEvent, render, screen } from '../../test/test-react-testing-library';
import { DeveloperType } from './DeveloperType';
import Developers from './Developers'; import Developers from './Developers';
import { DeveloperType } from './Title';
describe('test Developers', () => { describe('test Developers', () => {
afterEach(() => { afterEach(() => {
@ -63,7 +63,7 @@ describe('test Developers', () => {
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
test('should test onClick the component avatar', () => { test('should show only up to max items', () => {
const packageMeta = { const packageMeta = {
latest: { latest: {
packageName: 'foo', packageName: 'foo',
@ -72,6 +72,7 @@ describe('test Developers', () => {
{ {
name: 'dmethvin', name: 'dmethvin',
email: 'test@gmail.com', email: 'test@gmail.com',
url: 'https://example.com/',
}, },
{ {
name: 'dmethvin2', name: 'dmethvin2',
@ -89,14 +90,40 @@ describe('test Developers', () => {
<Developers packageMeta={packageMeta} type={DeveloperType.CONTRIBUTORS} visibleMax={1} /> <Developers packageMeta={packageMeta} type={DeveloperType.CONTRIBUTORS} visibleMax={1} />
); );
// const item2 = wrapper.find(Fab);
// // TODO: I am not sure here how to verify the method inside the component was called.
// item2.simulate('click');
expect(wrapper.getByText('sidebar.contributors.title')).toBeInTheDocument(); expect(wrapper.getByText('sidebar.contributors.title')).toBeInTheDocument();
fireEvent.click(wrapper.getByRole('button')); expect(wrapper.getByTestId(packageMeta.latest.contributors[0].name)).toBeInTheDocument();
expect(wrapper.queryByTestId(packageMeta.latest.contributors[1].name)).not.toBeInTheDocument();
expect(wrapper.queryByTestId(packageMeta.latest.contributors[2].name)).not.toBeInTheDocument();
});
expect(wrapper.getByLabelText(packageMeta.latest.contributors[0].name)).toBeInTheDocument(); test('renders only the first six contributors when there are more than six', () => {
expect(wrapper.getByLabelText(packageMeta.latest.contributors[1].name)).toBeInTheDocument(); const packageMeta = {
latest: {
contributors: [
{ name: 'contributor1', email: 'c1@test.com' },
{ name: 'contributor2', email: 'c2@test.com' },
{ name: 'contributor3', email: 'c3@test.com' },
{ name: 'contributor4', email: 'c4@test.com' },
{ name: 'contributor5', email: 'c5@test.com' },
{ name: 'contributor6', email: 'c6@test.com' },
{ name: 'contributor7', email: 'c7@test.com' },
],
},
};
render(<Developers packageMeta={packageMeta} type={DeveloperType.CONTRIBUTORS} />);
expect(screen.getByTestId('contributor1')).toBeInTheDocument();
expect(screen.getByTestId('contributor2')).toBeInTheDocument();
expect(screen.getByTestId('contributor3')).toBeInTheDocument();
expect(screen.getByTestId('contributor4')).toBeInTheDocument();
expect(screen.getByTestId('contributor5')).toBeInTheDocument();
expect(screen.getByTestId('contributor6')).toBeInTheDocument();
expect(screen.queryByTestId('contributor7')).not.toBeInTheDocument();
expect(screen.getByTestId('fab-add')).toBeInTheDocument();
// click on "more"
fireEvent.click(screen.getByTestId('fab-add'));
expect(screen.getByTestId('contributor7')).toBeInTheDocument();
}); });
}); });

@ -1,20 +1,15 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import Add from '@mui/icons-material/Add'; import Add from '@mui/icons-material/Add';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import FabMUI from '@mui/material/Fab'; import FabMUI from '@mui/material/Fab';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Theme } from '../../Theme'; import { Theme } from '../../Theme';
import Person from '../Person';
import { DeveloperType } from './DeveloperType';
import Title from './Title'; import Title from './Title';
import getUniqueDeveloperValues from './get-unique-developer-values'; import getUniqueDeveloperValues from './get-unique-developer-values';
export enum DeveloperType {
CONTRIBUTORS = 'contributors',
MAINTAINERS = 'maintainers',
}
export const Fab = styled(FabMUI)<{ theme?: Theme }>((props) => ({ export const Fab = styled(FabMUI)<{ theme?: Theme }>((props) => ({
backgroundColor: props.theme?.palette.primary.main, backgroundColor: props.theme?.palette.primary.main,
color: props.theme?.palette.white, color: props.theme?.palette.white,
@ -28,7 +23,7 @@ interface Props {
const StyledBox = styled(Box)({ const StyledBox = styled(Box)({
'> *': { '> *': {
margin: 5, marginRight: 5,
}, },
}); });
@ -58,19 +53,24 @@ const Developers: React.FC<Props> = ({ type, visibleMax = VISIBLE_MAX, packageMe
return null; return null;
} }
const { name: packageName, version } = packageMeta.latest;
return ( return (
<> <>
<Title type={type} /> <Title type={type} />
<StyledBox display="flex" flexWrap="wrap" margin="10px 0 10px 0"> <StyledBox display="flex" flexWrap="wrap" margin="10px 0 10px 0">
{visibleDevelopers.map((visibleDeveloper) => { {visibleDevelopers.map((visibleDeveloper, index) => {
return ( return (
<Tooltip key={visibleDeveloper.email} title={visibleDeveloper.name}> <Person
<Avatar alt={visibleDeveloper.name} src={visibleDeveloper.avatar} /> key={index}
</Tooltip> packageName={packageName}
person={visibleDeveloper}
version={version}
/>
); );
})} })}
{visibleDevelopersMax < developers.length && ( {visibleDevelopersMax < developers.length && (
<Fab onClick={handleSetVisibleDevelopersMax} size="small"> <Fab data-testid={'fab-add'} onClick={handleSetVisibleDevelopersMax} size="small">
<Add /> <Add />
</Fab> </Fab>
)} )}

@ -4,11 +4,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Theme } from '../../Theme'; import { Theme } from '../../Theme';
import { DeveloperType } from './';
export enum DeveloperType {
CONTRIBUTORS = 'contributors',
MAINTAINERS = 'maintainers',
}
interface Props { interface Props {
type: DeveloperType; type: DeveloperType;

@ -26,10 +26,26 @@ exports[`test Developers should render the component for contributors with items
} }
.emotion-3>* { .emotion-3>* {
margin: 5px; margin-right: 5px;
} }
.emotion-4 { .emotion-4 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-4:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-5 {
position: relative; position: relative;
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
@ -59,9 +75,13 @@ exports[`test Developers should render the component for contributors with items
user-select: none; user-select: none;
color: #f4f4f4; color: #f4f4f4;
background-color: #bdbdbd; background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
} }
.emotion-5 { .emotion-6 {
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
@ -90,14 +110,20 @@ exports[`test Developers should render the component for contributors with items
<div <div
class="emotion-2 MuiBox-root emotion-3" class="emotion-2 MuiBox-root emotion-3"
> >
<div <a
aria-label="dmethvin" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -107,14 +133,21 @@ exports[`test Developers should render the component for contributors with items
/> />
</svg> </svg>
</div> </div>
<div </a>
aria-label="mgol" <a
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="mgol"
href="mailto:m.goleb@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -124,6 +157,7 @@ exports[`test Developers should render the component for contributors with items
/> />
</svg> </svg>
</div> </div>
</a>
</div> </div>
</div> </div>
</body>, </body>,
@ -150,10 +184,26 @@ exports[`test Developers should render the component for contributors with items
} }
.emotion-3>* { .emotion-3>* {
margin: 5px; margin-right: 5px;
} }
.emotion-4 { .emotion-4 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-4:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-5 {
position: relative; position: relative;
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
@ -183,9 +233,13 @@ exports[`test Developers should render the component for contributors with items
user-select: none; user-select: none;
color: #f4f4f4; color: #f4f4f4;
background-color: #bdbdbd; background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
} }
.emotion-5 { .emotion-6 {
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
@ -213,14 +267,20 @@ exports[`test Developers should render the component for contributors with items
<div <div
class="emotion-2 MuiBox-root emotion-3" class="emotion-2 MuiBox-root emotion-3"
> >
<div <a
aria-label="dmethvin" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -230,14 +290,21 @@ exports[`test Developers should render the component for contributors with items
/> />
</svg> </svg>
</div> </div>
<div </a>
aria-label="mgol" <a
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="mgol"
href="mailto:m.goleb@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -247,6 +314,7 @@ exports[`test Developers should render the component for contributors with items
/> />
</svg> </svg>
</div> </div>
</a>
</div> </div>
</div>, </div>,
"debug": [Function], "debug": [Function],
@ -329,10 +397,26 @@ exports[`test Developers should render the component for maintainers with items
} }
.emotion-3>* { .emotion-3>* {
margin: 5px; margin-right: 5px;
} }
.emotion-4 { .emotion-4 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-4:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-5 {
position: relative; position: relative;
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
@ -362,9 +446,13 @@ exports[`test Developers should render the component for maintainers with items
user-select: none; user-select: none;
color: #f4f4f4; color: #f4f4f4;
background-color: #bdbdbd; background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
} }
.emotion-5 { .emotion-6 {
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
@ -393,14 +481,20 @@ exports[`test Developers should render the component for maintainers with items
<div <div
class="emotion-2 MuiBox-root emotion-3" class="emotion-2 MuiBox-root emotion-3"
> >
<div <a
aria-label="dmethvin" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -410,14 +504,21 @@ exports[`test Developers should render the component for maintainers with items
/> />
</svg> </svg>
</div> </div>
<div </a>
aria-label="mgol" <a
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="mgol"
href="mailto:m.goleb@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -427,6 +528,7 @@ exports[`test Developers should render the component for maintainers with items
/> />
</svg> </svg>
</div> </div>
</a>
</div> </div>
</div> </div>
</body>, </body>,
@ -453,10 +555,26 @@ exports[`test Developers should render the component for maintainers with items
} }
.emotion-3>* { .emotion-3>* {
margin: 5px; margin-right: 5px;
} }
.emotion-4 { .emotion-4 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-4:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-5 {
position: relative; position: relative;
display: -webkit-box; display: -webkit-box;
display: -webkit-flex; display: -webkit-flex;
@ -486,9 +604,13 @@ exports[`test Developers should render the component for maintainers with items
user-select: none; user-select: none;
color: #f4f4f4; color: #f4f4f4;
background-color: #bdbdbd; background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
} }
.emotion-5 { .emotion-6 {
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
@ -516,14 +638,20 @@ exports[`test Developers should render the component for maintainers with items
<div <div
class="emotion-2 MuiBox-root emotion-3" class="emotion-2 MuiBox-root emotion-3"
> >
<div <a
aria-label="dmethvin" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -533,14 +661,21 @@ exports[`test Developers should render the component for maintainers with items
/> />
</svg> </svg>
</div> </div>
<div </a>
aria-label="mgol" <a
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4" class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true" data-mui-internal-clone-element="true"
data-testid="mgol"
href="mailto:m.goleb@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon" data-testid="PersonIcon"
focusable="false" focusable="false"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -550,6 +685,7 @@ exports[`test Developers should render the component for maintainers with items
/> />
</svg> </svg>
</div> </div>
</a>
</div> </div>
</div>, </div>,
"debug": [Function], "debug": [Function],

@ -1 +1,2 @@
export { default, DeveloperType } from './Developers'; export { default } from './Developers';
export { DeveloperType } from './DeveloperType';

@ -1,31 +0,0 @@
import styled from '@emotion/styled';
import FabMUI from '@mui/material/Fab';
import Typography from '@mui/material/Typography';
import { Theme } from '../../Theme';
export const Details = styled('span')({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
});
export const Content = styled('div')({
margin: '10px 0 10px 0',
display: 'flex',
flexWrap: 'wrap',
'> *': {
margin: '5px',
},
});
export const StyledText = styled(Typography)<{ theme?: Theme }>((props) => ({
fontWeight: props.theme?.fontWeight.bold,
marginBottom: '10px',
textTransform: 'capitalize',
}));
export const Fab = styled(FabMUI)<{ theme?: Theme }>((props) => ({
backgroundColor: props.theme?.palette.primary.main,
color: props.theme?.palette.white,
}));

@ -33,7 +33,7 @@ describe('<Dist /> component', () => {
expect(getByText('sidebar.distribution.size')).toBeInTheDocument(); expect(getByText('sidebar.distribution.size')).toBeInTheDocument();
expect(getByText('sidebar.distribution.size')).toBeInTheDocument(); expect(getByText('sidebar.distribution.size')).toBeInTheDocument();
expect(getByText('7', { exact: false })).toBeInTheDocument(); expect(getByText('7', { exact: false })).toBeInTheDocument();
expect(getByText('10.00 Bytes', { exact: false })).toBeInTheDocument(); expect(getByText('10 Bytes', { exact: false })).toBeInTheDocument();
}); });
test('should render the component with license as string', () => { test('should render the component with license as string', () => {

@ -3,8 +3,8 @@ import React, { FC } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PackageMetaInterface } from '../../types/packageMeta'; import { PackageMetaInterface } from '../../types/packageMeta';
import { fileSizeSI, formatLicense } from '../../utils/utils';
import { DistChips, DistListItem, StyledText } from './styles'; import { DistChips, DistListItem, StyledText } from './styles';
import { fileSizeSI, formatLicense } from './utils';
const DistChip: FC<{ name: string; children?: React.ReactElement | string }> = ({ const DistChip: FC<{ name: string; children?: React.ReactElement | string }> = ({
name, name,
@ -47,9 +47,7 @@ const Dist: FC<{ packageMeta: PackageMetaInterface }> = ({ packageMeta }) => {
<DistChip name={t('sidebar.distribution.size')}>{fileSizeSI(dist.unpackedSize)}</DistChip> <DistChip name={t('sidebar.distribution.size')}>{fileSizeSI(dist.unpackedSize)}</DistChip>
) : null} ) : null}
<DistChip name={t('sidebar.distribution.license')}> <DistChip name={t('sidebar.distribution.license')}>{formatLicense(license)}</DistChip>
{formatLicense(license as string)}
</DistChip>
</DistListItem> </DistListItem>
</List> </List>
); );

@ -1,6 +1,5 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import FabMUI from '@mui/material/Fab';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
@ -22,8 +21,3 @@ export const DistChips = styled(Chip)({
textTransform: 'capitalize', textTransform: 'capitalize',
marginTop: 5, marginTop: 5,
}); });
export const DownloadButton = styled(FabMUI)<{ theme?: Theme }>((props) => ({
backgroundColor: props.theme?.palette.primary.main,
color: props.theme?.palette.white,
}));

@ -1,32 +0,0 @@
import { LicenseInterface } from '../../types/packageMeta';
/**
* Formats license field for webui.
* @see https://docs.npmjs.com/files/package.json#license
*/
// License should use type License defined above, but conflicts with the unit test that provide array or empty object
export function formatLicense(license: string | LicenseInterface): string | undefined {
if (typeof license === 'string') {
return license;
}
if (license?.type) {
return license.type;
}
return;
}
export function fileSizeSI(
a: number,
b?: typeof Math,
c?: (p: number) => number,
d?: number,
e?: number
): string {
return (
((b = Math), (c = b.log), (d = 1e3), (e = (c(a) / c(d)) | 0), a / b.pow(d, e)).toFixed(2) +
' ' +
(e ? 'kMGTPEZY'[--e] + 'B' : 'Bytes')
);
}

@ -1,17 +0,0 @@
import { fileSizeSI, formatLicense } from './utils';
test('formatLicense as string', () => {
expect(formatLicense('MIT')).toEqual('MIT');
});
test('formatLicense as format object', () => {
expect(formatLicense({ type: 'MIT' })).toEqual('MIT');
});
test('fileSizeSI as number 1000', () => {
expect(fileSizeSI(1000)).toEqual('1.00 kB');
});
test('fileSizeSI as number 0', () => {
expect(fileSizeSI(0)).toEqual('0.00 Bytes');
});

@ -23,8 +23,12 @@ const EngineItem: FC<EngineItemProps> = ({ title, element, engineText }) => (
<Grid item={true} xs={6}> <Grid item={true} xs={6}>
<List subheader={<StyledText variant={'subtitle1'}>{title}</StyledText>}> <List subheader={<StyledText variant={'subtitle1'}>{title}</StyledText>}>
<EngineListItem> <EngineListItem>
<Avatar sx={{ bgcolor: 'transparent' }}>{element}</Avatar> <Avatar sx={{ backgroundColor: 'transparent', marginLeft: 0, padding: 0 }}>
<Typography variant="subtitle2">{engineText}</Typography> {element}
</Avatar>
<Typography sx={{ margin: 0, padding: '0 0 0 10px' }} variant="subtitle2">
{engineText}
</Typography>
</EngineListItem> </EngineListItem>
</List> </List>
</Grid> </Grid>

@ -10,5 +10,5 @@ export const StyledText = styled(Typography)<{ theme?: Theme }>((props) => ({
})); }));
export const EngineListItem = styled(ListItem)({ export const EngineListItem = styled(ListItem)({
paddingLeft: 0, padding: 0,
}); });

@ -0,0 +1,39 @@
import React from 'react';
import { render, screen } from '../../test/test-react-testing-library';
import ErrorBoundary from './ErrorBoundary';
describe('ErrorBoundary component', () => {
test('should render children when no error is caught', () => {
render(
<ErrorBoundary>
<div>{'Test'}</div>
</ErrorBoundary>
);
expect(screen.getByText('Test')).toBeInTheDocument();
});
test('should render error information when error is caught', () => {
const ErrorComponent = () => {
throw new Error('Test error');
};
// Suppress error messages for this test
const spy = jest.spyOn(console, 'error');
spy.mockImplementation(() => {});
render(
<ErrorBoundary>
<ErrorComponent />
</ErrorBoundary>
);
expect(screen.getByText('Something went wrong.')).toBeInTheDocument();
expect(screen.getByText(/error:/)).toBeInTheDocument();
expect(screen.getByText(/info:/)).toBeInTheDocument();
// Restore console.error after test
spy.mockRestore();
});
});

@ -6,11 +6,11 @@ import { Trans } from 'react-i18next';
import { Theme } from '../../Theme'; import { Theme } from '../../Theme';
import { url } from '../../utils'; import { url } from '../../utils';
import { Link } from '../Link'; import LinkExternal from '../LinkExternal';
const StyledLink = styled(Link)<{ theme?: Theme }>(({ theme }) => ({ const StyledLink = styled(LinkExternal)<{ theme?: Theme }>(({ theme }) => ({
marginTop: theme?.spacing(1), marginTop: theme?.spacing(2),
marginBottom: theme?.spacing(1), marginBottom: theme?.spacing(2),
textDecoration: 'none', textDecoration: 'none',
display: 'block', display: 'block',
})); }));
@ -32,7 +32,7 @@ const FundButton: React.FC<{ packageMeta: any }> = ({ packageMeta }) => {
} }
return ( return (
<StyledLink external={true} to={fundingUrl} variant="button"> <StyledLink to={fundingUrl} variant="button">
<Button <Button
color="primary" color="primary"
fullWidth={true} fullWidth={true}

@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent, render, screen } from '../../test/test-react-testing-library';
import HeaderInfoDialog from './HeaderInfoDialog';
describe('HeaderInfoDialog', () => {
const onCloseDialog = jest.fn();
const tabs = [{ label: 'Tab 1' }, { label: 'Tab 2' }];
const tabPanels = [{ element: <div>{'Panel 1'}</div> }, { element: <div>{'Panel 2'}</div> }];
beforeEach(() => {
render(
<HeaderInfoDialog
dialogTitle="Dialog Title"
isOpen={true}
onCloseDialog={onCloseDialog}
tabPanels={tabPanels}
tabs={tabs}
/>
);
});
test('renders without crashing', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
test('displays the dialog title', () => {
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
});
test('renders the tabs correctly', () => {
expect(screen.getByText('Tab 1')).toBeInTheDocument();
expect(screen.getByText('Tab 2')).toBeInTheDocument();
});
test('renders the tab panels correctly', async () => {
expect(screen.getByText('Panel 1')).toBeInTheDocument();
// Panel 2 should not be visible initially
expect(screen.queryByText('Panel 2')).not.toBeInTheDocument();
// Switch to Tab 2
fireEvent.click(screen.getByText('Tab 2'));
await expect(screen.queryByText('Panel 2')).toBeInTheDocument();
});
test('calls onCloseDialog when the dialog is closed', () => {
fireEvent.click(screen.getByRole('button'));
expect(onCloseDialog).toHaveBeenCalled();
});
});

@ -54,7 +54,7 @@ const HeaderInfoDialog: React.FC<Props> = ({
<RegistryInfoDialog onClose={onCloseDialog} open={isOpen} title={dialogTitle}> <RegistryInfoDialog onClose={onCloseDialog} open={isOpen} title={dialogTitle}>
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}> <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs aria-label="infoTabs" onChange={handleChange} value={value}> <Tabs aria-label="infoTabs" data-testid={'tabs'} onChange={handleChange} value={value}>
{tabs {tabs
? tabs.map((item, index) => { ? tabs.map((item, index) => {
return ( return (
@ -68,7 +68,7 @@ const HeaderInfoDialog: React.FC<Props> = ({
{tabPanels {tabPanels
? tabPanels.map((item, index) => { ? tabPanels.map((item, index) => {
return ( return (
<TabPanel index={index} key={item.key} value={value}> <TabPanel index={index} key={index} value={value}>
{item.element} {item.element}
</TabPanel> </TabPanel>
); );

@ -0,0 +1,20 @@
import React from 'react';
import { render, screen } from '../../test/test-react-testing-library';
import Heading from './Heading';
describe('Heading component', () => {
test('should render correctly with default props', () => {
render(<Heading>{'Test'}</Heading>);
const headingElement = screen.getByText('Test');
expect(headingElement).toBeInTheDocument();
expect(headingElement.tagName).toBe('H6');
});
test('should render correctly with custom props', () => {
render(<Heading variant="h1">{'Test'}</Heading>);
const headingElement = screen.getByText('Test');
expect(headingElement).toBeInTheDocument();
expect(headingElement.tagName).toBe('H1');
});
});

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./commonjs.svg'); const icon = require('./commonjs.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function CommonJS() { export function CommonJS() {
return <ImgIcon alt="commonjs" height="20" src={icon} width="20" />; return <img alt="commonjs" height="20" src={icon} width="20" />;
} }

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./es6modules.svg'); const icon = require('./es6module.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function ES6Modules() { export function ES6Modules() {
return <ImgIcon alt="es6 modules" height="20" src={icon} width="20" />; return <img alt="es6 modules" height="20" src={icon} width="20" />;
} }

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./git.svg'); const icon = require('./git.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function Git() { export function Git() {
return <ImgIcon alt="git" height="20" src={icon} width="20" />; return <img alt="git" height="20" src={icon} width="20" />;
} }

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./nodejs.svg'); const icon = require('./nodejs.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function NodeJS() { export function NodeJS() {
return <ImgIcon alt="nodejs" height="20" src={icon} width="20" />; return <img alt="nodejs" height="20" src={icon} width="20" />;
} }

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./typescript.svg'); const icon = require('./typescript.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function TypeScript() { export function TypeScript() {
return <ImgIcon alt="typescript" height="20" src={icon} width="20" />; return <img alt="typescript" height="20" src={icon} width="20" />;
} }

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

@ -9,7 +9,8 @@ const FileBinary = React.forwardRef(function FileBinary(
ref: React.Ref<SVGSVGElement> ref: React.Ref<SVGSVGElement>
) { ) {
return ( return (
<SvgIcon {...props} ref={ref}> // eslint-disable-next-line verdaccio/jsx-spread
<SvgIcon viewBox="0 0 14 16" {...props} ref={ref}>
<path d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM5 6.98L3.5 8.5 5 10l-.5 1L2 8.5 4.5 6l.5.98zM7.5 6L10 8.5 7.5 11l-.5-.98L8.5 8.5 7 7l.5-1z" /> <path d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM5 6.98L3.5 8.5 5 10l-.5 1L2 8.5 4.5 6l.5.98zM7.5 6L10 8.5 7.5 11l-.5-.98L8.5 8.5 7 7l.5-1z" />
</SvgIcon> </SvgIcon>
); );

@ -6,13 +6,17 @@ import {
CommonJS, CommonJS,
ES6Modules, ES6Modules,
Earth, Earth,
FileBinary,
Git, Git,
Law, Law,
License, License,
NodeJS, NodeJS,
Npm,
Pnpm,
Time, Time,
TypeScript, TypeScript,
Version, Version,
Yarn,
} from '.'; } from '.';
export default { export default {
@ -21,16 +25,28 @@ export default {
export const Icons: any = () => ( export const Icons: any = () => (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<Stack spacing={2}> <Stack direction="row" spacing={2}>
<Npm />
<Pnpm />
<Yarn />
</Stack>
<Stack direction="row" spacing={2}>
<NodeJS /> <NodeJS />
<Git /> <Git />
<Version /> </Stack>
<Stack direction="row" spacing={2}>
<TypeScript /> <TypeScript />
<Time />
<License />
<Law />
<ES6Modules /> <ES6Modules />
<CommonJS /> <CommonJS />
</Stack>
<Stack direction="row" spacing={2}>
<Version />
<Time />
<FileBinary />
<Law />
</Stack>
<Stack direction="row" spacing={2}>
<License />
<Earth /> <Earth />
</Stack> </Stack>
</Box> </Box>

@ -0,0 +1,70 @@
import React from 'react';
import { render } from '../../test/test-react-testing-library';
import {
CommonJS,
ES6Modules,
Earth,
FileBinary,
Git,
Law,
License,
NodeJS,
Npm,
Pnpm,
Time,
TypeScript,
Version,
Yarn,
} from './';
import { SvgIcon } from './SvgIcon';
describe('Icon components', () => {
test('should render an SVG graphic', () => {
const { container } = render(
<>
<Earth />
<FileBinary />
<Law />
<License />
<Time />
<Version />
</>
);
expect(container.querySelectorAll('svg')).toHaveLength(6);
});
test('should render an IMG graphic linking to and SVG', () => {
const { container } = render(
<>
<CommonJS />
<ES6Modules />
<Git />
<NodeJS />
<TypeScript />
<Npm />
<Pnpm />
<Yarn />
</>
);
expect(container.querySelectorAll('img')).toHaveLength(8);
});
test('should render small graphic', () => {
const { container } = render(
<SvgIcon size={'sm'}>
<circle cx="7" cy="7" r="7" />
</SvgIcon>
);
expect(container.querySelector('svg')).toHaveStyle('width: 14px');
});
test('should render medium graphic', () => {
const { container } = render(
<SvgIcon size={'md'}>
<circle cx="7" cy="7" r="7" />
</SvgIcon>
);
expect(container.querySelector('svg')).toHaveStyle('width: 18px');
});
});

@ -6,7 +6,8 @@ type Props = React.ComponentProps<typeof SvgIcon>;
const Law = React.forwardRef(function Law(props: Props, ref: React.Ref<SVGSVGElement>) { const Law = React.forwardRef(function Law(props: Props, ref: React.Ref<SVGSVGElement>) {
return ( return (
<SvgIcon {...props} ref={ref}> // eslint-disable-next-line verdaccio/jsx-spread
<SvgIcon viewBox="0 0 14 16" {...props} ref={ref}>
<path <path
d="M7 4c-.83 0-1.5-.67-1.5-1.5S6.17 1 7 1s1.5.67 1.5 1.5S7.83 4 7 4zm7 6c0 1.11-.89 2-2 2h-1c-1.11 0-2-.89-2-2l2-4h-1c-.55 0-1-.45-1-1H8v8c.42 0 1 .45 1 1h1c.42 0 1 .45 1 1H3c0-.55.58-1 1-1h1c0-.55.58-1 1-1h.03L6 5H5c0 .55-.45 1-1 1H3l2 4c0 1.11-.89 2-2 2H2c-1.11 0-2-.89-2-2l2-4H1V5h3c0-.55.45-1 1-1h4c.55 0 1 .45 1 1h3v1h-1l2 4zM2.5 7L1 10h3L2.5 7zM13 10l-1.5-3-1.5 3h3z" d="M7 4c-.83 0-1.5-.67-1.5-1.5S6.17 1 7 1s1.5.67 1.5 1.5S7.83 4 7 4zm7 6c0 1.11-.89 2-2 2h-1c-1.11 0-2-.89-2-2l2-4h-1c-.55 0-1-.45-1-1H8v8c.42 0 1 .45 1 1h1c.42 0 1 .45 1 1H3c0-.55.58-1 1-1h1c0-.55.58-1 1-1h.03L6 5H5c0 .55-.45 1-1 1H3l2 4c0 1.11-.89 2-2 2H2c-1.11 0-2-.89-2-2l2-4H1V5h3c0-.55.45-1 1-1h4c.55 0 1 .45 1 1h3v1h-1l2 4zM2.5 7L1 10h3L2.5 7zM13 10l-1.5-3-1.5 3h3z"
fillRule="evenodd" fillRule="evenodd"

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./npm.svg'); const icon = require('./npm.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function Npm() { export function Npm() {
return <ImgIcon alt="npm package manager" height="20" src={icon} width="20" />; return <img alt="npm package manager" height="20" src={icon} width="20" />;
} }

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./pnpm.svg'); const icon = require('./pnpm.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function Pnpm() { export function Pnpm() {
return <ImgIcon alt="pnpm package manager" height="20" src={icon} width="20" />; return <img alt="pnpm package manager" height="20" src={icon} width="20" />;
} }

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react'; import React from 'react';
const icon = require('./yarn.svg'); const icon = require('./yarn.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
export function Yarn() { export function Yarn() {
return <ImgIcon alt="npm package manager" height="20" src={icon} width="20" />; return <img alt="npm package manager" height="20" src={icon} width="20" />;
} }

@ -5,12 +5,14 @@ import { PackageManagers } from '@verdaccio/types';
import { useConfig } from '../../providers'; import { useConfig } from '../../providers';
import { render, screen } from '../../test/test-react-testing-library'; import { render, screen } from '../../test/test-react-testing-library';
import Install from './Install'; import Install from './Install';
import { getGlobalInstall } from './InstallListItem'; import InstallListItem, { DependencyManager, getGlobalInstall } from './InstallListItem';
import data from './__partials__/data.json'; import data from './__partials__/data.json';
const ComponentToBeRendered: React.FC<{ pkgManagers?: PackageManagers[] }> = () => { const ComponentToBeRendered: React.FC<{ name?: string; pkgManagers?: PackageManagers[] }> = ({
name = 'foo',
}) => {
const { configOptions } = useConfig(); const { configOptions } = useConfig();
return <Install configOptions={configOptions} packageMeta={data} packageName="foo" />; return <Install configOptions={configOptions} packageMeta={data} packageName={name} />;
}; };
/* eslint-disable react/jsx-no-bind*/ /* eslint-disable react/jsx-no-bind*/
@ -22,6 +24,11 @@ describe('<Install />', () => {
expect(screen.getByText('npm install foo@8.0.0')).toBeInTheDocument(); expect(screen.getByText('npm install foo@8.0.0')).toBeInTheDocument();
}); });
test('should not render if name is missing', () => {
render(<ComponentToBeRendered name="" />);
expect(screen.queryByTestId('installList')).toBeNull();
});
test('should have 3 children', () => { test('should have 3 children', () => {
window.__VERDACCIO_BASENAME_UI_OPTIONS.pkgManagers = ['yarn', 'pnpm', 'npm']; window.__VERDACCIO_BASENAME_UI_OPTIONS.pkgManagers = ['yarn', 'pnpm', 'npm'];
const { getByTestId } = render(<ComponentToBeRendered />); const { getByTestId } = render(<ComponentToBeRendered />);
@ -32,9 +39,7 @@ describe('<Install />', () => {
test('should have the element NPM', () => { test('should have the element NPM', () => {
window.__VERDACCIO_BASENAME_UI_OPTIONS.pkgManagers = ['npm']; window.__VERDACCIO_BASENAME_UI_OPTIONS.pkgManagers = ['npm'];
render(<ComponentToBeRendered />); render(<ComponentToBeRendered />);
expect(screen.getByText('sidebar.installation.title')).toBeTruthy(); expect(screen.getByText('sidebar.installation.title')).toBeTruthy();
expect(screen.queryByText('pnpm')).not.toBeInTheDocument(); expect(screen.queryByText('pnpm')).not.toBeInTheDocument();
expect(screen.queryByText('yarn')).not.toBeInTheDocument(); expect(screen.queryByText('yarn')).not.toBeInTheDocument();
@ -59,18 +64,54 @@ describe('<Install />', () => {
}); });
}); });
describe('getGlobalInstall', () => { describe('<InstallListItem />', () => {
test('no global', () => { test('renders correctly', () => {
expect(getGlobalInstall(false, 'foo', '1.0.0')).toEqual('1.0.0@foo'); render(
}); <InstallListItem
test('global', () => { dependencyManager={DependencyManager.NPM}
expect(getGlobalInstall(true, 'foo', '1.0.0')).toEqual('-g 1.0.0@foo'); packageName={'foo'}
packageVersion={'8.0.0'}
/>
);
expect(screen.queryByTestId('installListItem-npm')).toBeInTheDocument();
}); });
test('yarn no global', () => { test('should not render if name is missing', () => {
expect(getGlobalInstall(false, 'foo', '1.0.0', true)).toEqual('1.0.0@foo'); render(
}); // @ts-ignore - testing invalid value
test('yarn global', () => { <InstallListItem dependencyManager={'other'} packageName={'foo'} packageVersion={'8.0.0'} />
expect(getGlobalInstall(true, 'foo', '1.0.0', true)).toEqual('1.0.0@foo'); );
// expect nothing to be rendered
expect(screen.queryByTestId('installListItem-npm')).toBeNull();
});
});
describe('getGlobalInstall', () => {
test('version', () => {
expect(getGlobalInstall(false, false, '1.0.0', 'foo')).toEqual('foo@1.0.0');
});
test('latest', () => {
expect(getGlobalInstall(true, false, '1.0.0', 'foo')).toEqual('foo');
});
test('version global', () => {
expect(getGlobalInstall(false, true, '1.0.0', 'foo')).toEqual('-g foo@1.0.0');
});
test('latest global', () => {
expect(getGlobalInstall(true, true, '1.0.0', 'foo')).toEqual('-g foo');
});
test('yarn version', () => {
expect(getGlobalInstall(false, false, '1.0.0', 'foo', true)).toEqual('foo@1.0.0');
});
test('yarn latest', () => {
expect(getGlobalInstall(true, false, '1.0.0', 'foo', true)).toEqual('foo');
});
test('yarn version global', () => {
expect(getGlobalInstall(false, true, '1.0.0', 'foo', true)).toEqual('foo@1.0.0');
});
test('yarn latest global', () => {
expect(getGlobalInstall(true, true, '1.0.0', 'foo', true)).toEqual('foo');
}); });
}); });

@ -1,8 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Typography } from '@mui/material'; import { Typography } from '@mui/material';
import Grid from '@mui/material/Grid';
import List from '@mui/material/List'; import List from '@mui/material/List';
import { useTheme } from '@mui/styles';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -18,6 +16,12 @@ const StyledText = styled(Typography)<{ theme?: Theme }>((props) => ({
textTransform: 'capitalize', textTransform: 'capitalize',
})); }));
const Wrapper = styled('div')({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export type Props = { export type Props = {
packageMeta: PackageMetaInterface; packageMeta: PackageMetaInterface;
packageName: string; packageName: string;
@ -26,7 +30,6 @@ export type Props = {
const Install: React.FC<Props> = ({ packageMeta, packageName, configOptions }) => { const Install: React.FC<Props> = ({ packageMeta, packageName, configOptions }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
if (!packageMeta || !packageName) { if (!packageMeta || !packageName) {
return null; return null;
} }
@ -38,16 +41,14 @@ const Install: React.FC<Props> = ({ packageMeta, packageName, configOptions }) =
return hasPkgManagers ? ( return hasPkgManagers ? (
<> <>
<Grid
container={true}
justifyContent="flex-end"
sx={{ marginRight: theme.spacing(10), alingText: 'right' }}
>
<SettingsMenu packageName={packageName} />
</Grid>
<List <List
data-testid={'installList'} data-testid={'installList'}
subheader={<StyledText variant={'subtitle1'}>{t('sidebar.installation.title')}</StyledText>} subheader={
<Wrapper>
<StyledText variant={'subtitle1'}>{t('sidebar.installation.title')}</StyledText>
<SettingsMenu packageName={packageName} />
</Wrapper>
}
> >
{hasNpm && ( {hasNpm && (
<InstallListItem <InstallListItem

@ -22,9 +22,9 @@ const InstallListItemText = styled(ListItemText)({
}); });
const PackageMangerAvatar = styled(Avatar)({ const PackageMangerAvatar = styled(Avatar)({
borderRadius: '0px',
backgroundColor: 'transparent', backgroundColor: 'transparent',
padding: 0, padding: 0,
marginLeft: 0,
}); });
export enum DependencyManager { export enum DependencyManager {
@ -39,10 +39,10 @@ interface Interface {
packageVersion?: string; packageVersion?: string;
} }
export function getGlobalInstall(isGlobal, packageVersion, packageName, isYarn = false) { export function getGlobalInstall(isLatest, isGlobal, packageVersion, packageName, isYarn = false) {
const name = isGlobal const name = isGlobal
? `${isYarn ? '' : '-g'} ${packageVersion ? `${packageName}@${packageVersion}` : packageName}` ? `${isYarn ? '' : '-g'} ${packageVersion && !isLatest ? `${packageName}@${packageVersion}` : packageName}`
: packageVersion : packageVersion && !isLatest
? `${packageName}@${packageVersion}` ? `${packageName}@${packageVersion}`
: packageName; : packageName;
@ -56,6 +56,7 @@ const InstallListItem: React.FC<Interface> = ({
}) => { }) => {
const { localSettings } = useSettings(); const { localSettings } = useSettings();
const theme = useTheme(); const theme = useTheme();
const isLatest = localSettings[packageName]?.latest ?? false;
const isGlobal = localSettings[packageName]?.global ?? false; const isGlobal = localSettings[packageName]?.global ?? false;
switch (dependencyManager) { switch (dependencyManager) {
case DependencyManager.NPM: case DependencyManager.NPM:
@ -67,9 +68,9 @@ const InstallListItem: React.FC<Interface> = ({
<InstallListItemText <InstallListItemText
primary={ primary={
<CopyToClipBoard <CopyToClipBoard
dataTestId="instalNpm" dataTestId="installNpm"
text={`npm install ${getGlobalInstall(isGlobal, packageVersion, packageName)}`} text={`npm install ${getGlobalInstall(isLatest, isGlobal, packageVersion, packageName)}`}
title={`npm install ${getGlobalInstall(isGlobal, packageVersion, packageName)}`} title={`npm install ${getGlobalInstall(isLatest, isGlobal, packageVersion, packageName)}`}
/> />
} }
/> />
@ -93,7 +94,7 @@ const InstallListItem: React.FC<Interface> = ({
packageName, packageName,
true true
)}` )}`
: `yarn add ${getGlobalInstall(isGlobal, packageVersion, packageName, true)}` : `yarn add ${getGlobalInstall(isLatest, isGlobal, packageVersion, packageName, true)}`
} }
title={ title={
isGlobal isGlobal
@ -103,7 +104,7 @@ const InstallListItem: React.FC<Interface> = ({
packageName, packageName,
true true
)}` )}`
: `yarn add ${getGlobalInstall(isGlobal, packageVersion, packageName, true)}` : `yarn add ${getGlobalInstall(isLatest, isGlobal, packageVersion, packageName, true)}`
} }
/> />
} }
@ -120,8 +121,8 @@ const InstallListItem: React.FC<Interface> = ({
primary={ primary={
<CopyToClipBoard <CopyToClipBoard
dataTestId="installPnpm" dataTestId="installPnpm"
text={`pnpm install ${getGlobalInstall(isGlobal, packageVersion, packageName)}`} text={`pnpm install ${getGlobalInstall(isLatest, isGlobal, packageVersion, packageName)}`}
title={`pnpm install ${getGlobalInstall(isGlobal, packageVersion, packageName)}`} title={`pnpm install ${getGlobalInstall(isLatest, isGlobal, packageVersion, packageName)}`}
/> />
} }
/> />

@ -0,0 +1,22 @@
import Chip from '@mui/material/Chip';
import ListItem from '@mui/material/ListItem';
import React from 'react';
const KeywordListItems: React.FC<{ keywords: undefined | string | string[] }> = ({ keywords }) => {
const keywordList =
typeof keywords === 'string' ? keywords.replace(/,/g, ' ').split(' ') : keywords;
if (!keywordList) {
return null;
}
return (
<ListItem sx={{ px: 0, mt: 0, flexWrap: 'wrap' }}>
{keywordList.sort().map((keyword, index) => (
<Chip key={index} label={keyword} sx={{ mt: 1, mr: 1 }} />
))}
</ListItem>
);
};
export default KeywordListItems;

@ -0,0 +1,19 @@
import React from 'react';
import { default as Keywords } from '.';
export default {
title: 'Components/Sidebar/Keywords',
};
export const AllProperties: any = () => (
<Keywords
packageMeta={{
latest: {
name: 'verdaccio1',
version: '4.0.0',
keywords: ['verdaccio', 'npm', 'yarn'],
},
}}
/>
);

@ -0,0 +1,57 @@
import React from 'react';
import { render, screen } from '../../test/test-react-testing-library';
import Keywords from './Keywords';
describe('<Keywords /> component', () => {
test('should render the component in default state', () => {
const packageMeta = {
latest: {
name: 'verdaccio1',
version: '4.0.0',
keywords: ['verdaccio', 'npm', 'yarn'],
},
};
const container = render(<Keywords packageMeta={packageMeta} />);
expect(container.getByText('sidebar.keywords.title')).toBeInTheDocument();
expect(container.getByText('verdaccio')).toBeInTheDocument();
expect(container.getByText('npm')).toBeInTheDocument();
expect(container.getByText('yarn')).toBeInTheDocument();
});
test('should not render if data is missing', () => {
// @ts-ignore
render(<Keywords packageMeta={{}} />);
expect(screen.queryByTestId('keyword-list')).toBeNull();
const packageMeta = {
latest: {
name: 'verdaccio1',
version: '4.0.0',
keywords: '',
},
};
render(<Keywords packageMeta={packageMeta} />);
expect(screen.queryByTestId('keyword-list')).toBeNull();
});
test('should render keywords set in string', () => {
const packageMeta = {
latest: {
name: 'verdaccio1',
version: '4.0.0',
keywords: 'hello, world, verdaccio',
},
};
const container = render(<Keywords packageMeta={packageMeta} />);
expect(container.getByText('sidebar.keywords.title')).toBeInTheDocument();
expect(container.getByText('verdaccio')).toBeInTheDocument();
expect(container.getByText('hello')).toBeInTheDocument();
expect(container.getByText('world')).toBeInTheDocument();
});
});

@ -0,0 +1,35 @@
import List from '@mui/material/List';
import Typography from '@mui/material/Typography';
import { useTheme } from '@mui/styles';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { PackageMetaInterface } from '../../types/packageMeta';
import KeywordListItems from './KeywordListItems';
const Keywords: React.FC<{ packageMeta: PackageMetaInterface }> = ({ packageMeta }) => {
const { t } = useTranslation();
const theme = useTheme();
if (!packageMeta?.latest?.keywords) {
return null;
}
return (
<List
data-testid="keyword-list"
subheader={
<Typography
sx={{ fontWeight: theme.fontWeight.bold, textTransform: 'capitalize' }}
variant="subtitle1"
>
{t('sidebar.keywords.title')}
</Typography>
}
>
<KeywordListItems keywords={packageMeta?.latest?.keywords} />
</List>
);
};
export default Keywords;

@ -0,0 +1 @@
export { default } from './Keywords';

@ -0,0 +1,25 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { render } from '../../test/test-react-testing-library';
import Link from './Link';
describe('<Link /> component', () => {
test('should render the component in default state', () => {
const { container } = render(
<Router>
<Link to={'/'} />
</Router>
);
expect(container.firstChild).toMatchSnapshot();
});
test('should render the component with link', () => {
const { container } = render(
<Router>
<Link to={'/'}>{'Home'}</Link>
</Router>
);
expect(container.firstChild).toMatchSnapshot();
});
});

@ -3,9 +3,7 @@ import Typography from '@mui/material/Typography';
import React from 'react'; import React from 'react';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
type LinkRef = HTMLAnchorElement; const CustomRouterLink = styled(RouterLink)`
export const CustomRouterLink = styled(RouterLink)`
text-decoration: none; text-decoration: none;
&:hover, &:hover,
&:focus { &:focus {
@ -13,23 +11,11 @@ export const CustomRouterLink = styled(RouterLink)`
} }
`; `;
// TODO: improve any with custom types for a and RouterLink const Link = React.forwardRef<HTMLAnchorElement, any>(function LinkFunction(
const Link = React.forwardRef<LinkRef, any>(function LinkFunction( { to, children, variant, className, onClick },
{ external, to, children, variant, className, onClick },
ref ref
) { ) {
return external ? ( return (
<a
className={className}
href={to}
onClick={onClick}
ref={ref}
rel="noopener noreferrer"
target="_blank"
>
<Typography variant={variant ?? 'caption'}>{children}</Typography>
</a>
) : (
<CustomRouterLink className={className} innerRef={ref} onClick={onClick} to={to}> <CustomRouterLink className={className} innerRef={ref} onClick={onClick} to={to}>
<Typography variant={variant}>{children}</Typography> <Typography variant={variant}>{children}</Typography>
</CustomRouterLink> </CustomRouterLink>

@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Link /> component should render the component in default state 1`] = `
.emotion-0 {
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-0:hover,
.emotion-0:focus {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-2 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 1rem;
line-height: 1.5;
}
<a
class="emotion-0 emotion-1"
href="/"
>
<p
class="MuiTypography-root MuiTypography-body1 emotion-2"
/>
</a>
`;
exports[`<Link /> component should render the component with link 1`] = `
.emotion-0 {
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-0:hover,
.emotion-0:focus {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.emotion-2 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 1rem;
line-height: 1.5;
}
<a
class="emotion-0 emotion-1"
href="/"
>
<p
class="MuiTypography-root MuiTypography-body1 emotion-2"
>
Home
</p>
</a>
`;

@ -1 +1 @@
export { default as Link } from './Link'; export { default } from './Link';

@ -0,0 +1,18 @@
import React from 'react';
import { render } from '../../test/test-react-testing-library';
import LinkExternal from './LinkExternal';
describe('<LinkExternal /> component', () => {
test('should render the component in default state', () => {
const { container } = render(<LinkExternal to={'/'} />);
expect(container.firstChild).toMatchSnapshot();
});
test('should render the component with external link', () => {
const { container } = render(
<LinkExternal to={'https://example.com'}>{'Example'}</LinkExternal>
);
expect(container.firstChild).toMatchSnapshot();
});
});

@ -0,0 +1,22 @@
import Link from '@mui/material/Link';
import React from 'react';
const LinkExternal = React.forwardRef<HTMLAnchorElement, any>((props, ref) => {
const { to, children, variant, ...rest } = props;
return (
// eslint-disable-next-line verdaccio/jsx-spread
<Link
href={to}
ref={ref}
rel="noopener noreferrer"
target="_blank"
underline="hover"
variant={variant ?? 'caption'}
{...rest}
>
{children}
</Link>
);
});
export default LinkExternal;

@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LinkExternal /> component should render the component in default state 1`] = `
.emotion-0 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-0:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-0"
href="/"
rel="noopener noreferrer"
target="_blank"
/>
`;
exports[`<LinkExternal /> component should render the component with external link 1`] = `
.emotion-0 {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-weight: 400;
font-size: 0.75rem;
line-height: 1.66;
color: #4b5e40;
-webkit-text-decoration: none;
text-decoration: none;
}
.emotion-0:hover {
-webkit-text-decoration: underline;
text-decoration: underline;
}
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-0"
href="https://example.com"
rel="noopener noreferrer"
target="_blank"
>
Example
</a>
`;

@ -0,0 +1 @@
export { default } from './LinkExternal';

@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import { render, screen } from '../../test/test-react-testing-library'; import { act, render, screen, waitFor } from '../../test/test-react-testing-library';
import Loading from './Loading'; import Loading from './Loading';
describe('<Loading /> component', () => { describe('<Loading /> component', () => {
test('should render the component in default state', () => { test('should render the component in default state', async () => {
act(() => {
render(<Loading />); render(<Loading />);
screen.debug(); });
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument(); expect(screen.getByTestId('loading')).toBeInTheDocument();
}); });
}); });
});

@ -11,6 +11,7 @@ export const Wrapper = styled('div')({
export const Badge = styled('div')<{ theme?: Theme }>(({ theme }) => ({ export const Badge = styled('div')<{ theme?: Theme }>(({ theme }) => ({
margin: '0 0 30px 0', margin: '0 0 30px 0',
padding: 5,
borderRadius: 25, borderRadius: 25,
boxShadow: '0 10px 20px 0 rgba(69, 58, 100, 0.2)', boxShadow: '0 10px 20px 0 rgba(69, 58, 100, 0.2)',
background: theme?.palette.mode === 'dark' ? theme?.palette.black : '#f7f8f6', background: theme?.palette.mode === 'dark' ? theme?.palette.black : '#f7f8f6',

@ -87,7 +87,7 @@ describe('<LoginDialog /> component', () => {
fireEvent.change(userNameInput, { target: { value: 'xyz' } }); fireEvent.change(userNameInput, { target: { value: 'xyz' } });
const passwordInput = screen.getByPlaceholderText('form-placeholder.password'); const passwordInput = screen.getByPlaceholderText('form-placeholder.password');
expect(userNameInput).toBeInTheDocument(); expect(passwordInput).toBeInTheDocument();
fireEvent.focus(passwordInput); fireEvent.focus(passwordInput);
await act(async () => { await act(async () => {

@ -21,7 +21,7 @@ const LoginDialog: React.FC<Props> = ({ onClose, open = false }) => {
const makeLogin = useCallback( const makeLogin = useCallback(
async (username?: string, password?: string): Promise<LoginBody | void> => { async (username?: string, password?: string): Promise<LoginBody | void> => {
// checks isEmpty // checks isEmpty
if (isEmpty(username) || isEmpty(password)) { if (!username || !password || isEmpty(username) || isEmpty(password)) {
dispatch.login.addError({ dispatch.login.addError({
type: 'error', type: 'error',
description: i18next.t('form-validation.username-or-password-cant-be-empty'), description: i18next.t('form-validation.username-or-password-cant-be-empty'),
@ -29,6 +29,15 @@ const LoginDialog: React.FC<Props> = ({ onClose, open = false }) => {
return; return;
} }
// checks min username and password length
if (username.length < 2 || password.length < 2) {
dispatch.login.addError({
type: 'error',
description: i18next.t('form-validation.required-min-length', { length: 2 }),
});
return;
}
try { try {
dispatch.login.getUser({ username, password }); dispatch.login.getUser({ username, password });
// const response: LoginBody = await doLogin(username as string, password as string); // const response: LoginBody = await doLogin(username as string, password as string);

@ -12,6 +12,7 @@ const StyledIconButton = styled(IconButton)<{ theme?: Theme }>(({ theme }) => ({
right: theme.spacing() / 2, right: theme.spacing() / 2,
top: theme.spacing() / 2, top: theme.spacing() / 2,
color: theme.palette.grey[500], color: theme.palette.grey[500],
zIndex: 99,
})); }));
interface Props { interface Props {

@ -48,7 +48,6 @@ const LoginDialogForm = memo(({ onSubmit, error }: Props) => {
id="login--dialog-username" id="login--dialog-username"
{...register('username', { {...register('username', {
required: { value: true, message: t('form-validation.required-field') }, required: { value: true, message: t('form-validation.required-field') },
minLength: { value: 2, message: t('form-validation.required-min-length', { length: 2 }) },
})} })}
label={t('form.username')} label={t('form.username')}
margin="normal" margin="normal"
@ -65,7 +64,6 @@ const LoginDialogForm = memo(({ onSubmit, error }: Props) => {
id="login--dialog-password" id="login--dialog-password"
{...register('password', { {...register('password', {
required: { value: true, message: t('form-validation.required-field') }, required: { value: true, message: t('form-validation.required-field') },
minLength: { value: 2, message: t('form-validation.required-min-length', { length: 2 }) },
})} })}
data-testid="password" data-testid="password"
label={t('form.password')} label={t('form.password')}

@ -30,7 +30,7 @@ const LoginDialogFormError = memo(({ error }: Props) => {
return ( return (
<StyledSnackbarContent <StyledSnackbarContent
message={ message={
<Box alignItems="center" display="flex"> <Box alignItems="center" data-testid="error" display="flex">
<StyledErrorIcon /> <StyledErrorIcon />
{error.description} {error.description}
</Box> </Box>

@ -0,0 +1,16 @@
import Box from '@mui/material/Box';
import React from 'react';
import Logo from './Logo';
export default {
title: 'Components/Logo',
};
export const Icons: any = () => (
<Box sx={{ width: '100%' }}>
<Logo size="x-small" />
<Logo size="small" />
<Logo size="big" />
</Box>
);

@ -1,11 +1,18 @@
import React from 'react'; import React from 'react';
import { render } from '../../test/test-react-testing-library'; import { render, screen } from '../../test/test-react-testing-library';
import Logo from './Logo'; import Logo from './Logo';
describe('<Logo /> component', () => { describe('<Logo /> component', () => {
test('should render the component in default state', () => { test('should render the component in default state', () => {
const { container } = render(<Logo />); const { container } = render(<Logo />);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
expect(screen.getByTestId('default-logo')).toBeTruthy();
});
test('should render custom logo', () => {
window.__VERDACCIO_BASENAME_UI_OPTIONS.logo = 'custom.png';
render(<Logo />);
expect(screen.getByTestId('custom-logo')).toBeTruthy();
}); });
}); });

@ -1,4 +1,5 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useTheme } from '@mui/styles';
import React from 'react'; import React from 'react';
import { Theme, useConfig } from '../../'; import { Theme, useConfig } from '../../';
@ -22,18 +23,33 @@ interface Props {
onClick?: () => void; onClick?: () => void;
className?: string; className?: string;
isDefault?: boolean; isDefault?: boolean;
title?: string;
} }
const Logo: React.FC<Props> = ({ size, onClick, className, isDefault = false }) => { const Logo: React.FC<Props> = ({ size, onClick, className, isDefault = false, title = '' }) => {
const { configOptions } = useConfig(); const { configOptions } = useConfig();
const theme = useTheme();
if (!isDefault && configOptions?.logo) { if (!isDefault && configOptions?.logo) {
const logoSrc =
theme?.palette.mode === 'dark' && configOptions.logoDark
? configOptions.logoDark
: configOptions.logo;
return ( return (
<ImageLogo className={className} onClick={onClick}> <ImageLogo className={className} onClick={onClick}>
<img alt="logo" height="40px" src={configOptions.logo} /> <img alt={title} data-testid={'custom-logo'} height="40px" src={logoSrc} />
</ImageLogo> </ImageLogo>
); );
} }
return <StyledLogo className={className} onClick={onClick} size={size} />;
return (
<StyledLogo
className={className}
data-testid={'default-logo'}
onClick={onClick}
size={size}
title={title}
/>
);
}; };
export default Logo; export default Logo;

@ -17,5 +17,7 @@ exports[`<Logo /> component should render the component in default state 1`] = `
<div <div
class="emotion-0 emotion-1" class="emotion-0 emotion-1"
data-testid="default-logo"
title=""
/> />
`; `;

@ -2,7 +2,13 @@ import React from 'react';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { store } from '../../'; import { store } from '../../';
import { cleanup, renderWithStore } from '../../test/test-react-testing-library'; import {
cleanup,
fireEvent,
renderWithStore,
screen,
waitFor,
} from '../../test/test-react-testing-library';
import Package from './Package'; import Package from './Package';
/** /**
@ -11,12 +17,6 @@ import Package from './Package';
*/ */
const dateOneMonthAgo = (): Date => new Date(1544377770747); const dateOneMonthAgo = (): Date => new Date(1544377770747);
describe('<Package /> component', () => {
afterEach(() => {
cleanup();
});
test('should load the component', () => {
const props = { const props = {
name: 'verdaccio', name: 'verdaccio',
version: '1.0.0', version: '1.0.0',
@ -26,14 +26,27 @@ describe('<Package /> component', () => {
author: { author: {
name: 'Sam', name: 'Sam',
}, },
keywords: ['verdaccio'], dist: {
fileCount: 1,
unpackedSize: 171,
tarball: 'http://localhost:9000/verdaccio/-/verdaccio-1.0.0.tgz',
},
keywords: ['verdaccio-core'],
}; };
describe('<Package /> component', () => {
afterEach(() => {
cleanup();
});
test('should load the component', () => {
const wrapper = renderWithStore( const wrapper = renderWithStore(
<MemoryRouter> <MemoryRouter>
<Package <Package
author={props.author} author={props.author}
description={props.description} description={props.description}
dist={props.dist}
keywords={props.keywords}
license={props.license} license={props.license}
name={props.name} name={props.name}
time={props.time} time={props.time}
@ -50,5 +63,25 @@ describe('<Package /> component', () => {
expect(wrapper.getByText('MIT')).toBeInTheDocument(); expect(wrapper.getByText('MIT')).toBeInTheDocument();
}); });
test.todo('should load the component without author'); // test if click on download button will trigger the download action
test('should download the package', async () => {
renderWithStore(
<MemoryRouter>
<Package
author={props.author}
description={props.description}
dist={props.dist}
keywords={props.keywords}
license={props.license}
name={props.name}
time={props.time}
version={props.version}
/>
</MemoryRouter>,
store
);
fireEvent.click(screen.getByTestId('download-tarball'));
await waitFor(() => expect(store.getState().loading.models.download).toBe(true));
});
}); });

@ -3,20 +3,20 @@ import styled from '@emotion/styled';
import BugReport from '@mui/icons-material/BugReport'; import BugReport from '@mui/icons-material/BugReport';
import DownloadIcon from '@mui/icons-material/CloudDownload'; import DownloadIcon from '@mui/icons-material/CloudDownload';
import HomeIcon from '@mui/icons-material/Home'; import HomeIcon from '@mui/icons-material/Home';
import { useTheme } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Dispatch, Link, RootState, Theme } from '../../'; import { Dispatch, Link, LinkExternal, RootState, Theme } from '../../';
import { FileBinary, Law, Time, Version } from '../../components/Icons'; import { FileBinary, Law, Time, Version } from '../../components/Icons';
import { Author as PackageAuthor, PackageMetaInterface } from '../../types/packageMeta'; import { Author as PackageAuthor, PackageMetaInterface } from '../../types/packageMeta';
import { url, utils } from '../../utils'; import { Route, url, utils } from '../../utils';
import Tag from './Tag'; import KeywordListItems from '../Keywords/KeywordListItems';
import { import {
Author, Author,
Avatar, Avatar,
@ -28,7 +28,6 @@ import {
PackageListItemText, PackageListItemText,
PackageTitle, PackageTitle,
Published, Published,
TagContainer,
Wrapper, Wrapper,
WrapperLink, WrapperLink,
} from './styles'; } from './styles';
@ -47,7 +46,7 @@ export interface PackageInterface {
time?: number | string; time?: number | string;
author: PackageAuthor; author: PackageAuthor;
description?: string; description?: string;
keywords?: string[]; keywords?: PackageMetaInterface['latest']['keywords'];
license?: PackageMetaInterface['latest']['license']; license?: PackageMetaInterface['latest']['license'];
homepage?: string; homepage?: string;
bugs?: Bugs; bugs?: Bugs;
@ -61,7 +60,7 @@ const Package: React.FC<PackageInterface> = ({
description, description,
dist, dist,
homepage, homepage,
keywords = [], keywords,
license, license,
name: packageName, name: packageName,
time, time,
@ -71,7 +70,6 @@ const Package: React.FC<PackageInterface> = ({
const config = useSelector((state: RootState) => state.configuration.config); const config = useSelector((state: RootState) => state.configuration.config);
const dispatch = useDispatch<Dispatch>(); const dispatch = useDispatch<Dispatch>();
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
const isLoading = useSelector((state: RootState) => state?.loading?.models.download); const isLoading = useSelector((state: RootState) => state?.loading?.models.download);
const handleDownload = useCallback( const handleDownload = useCallback(
@ -94,21 +92,12 @@ const Package: React.FC<PackageInterface> = ({
const renderAuthorInfo = (): React.ReactNode => { const renderAuthorInfo = (): React.ReactNode => {
const name = utils.getAuthorName(authorName); const name = utils.getAuthorName(authorName);
return ( return (
<OverviewItem>
<Author> <Author>
<Avatar alt={name} src={authorAvatar} /> <Avatar alt={name} src={authorAvatar} />
<Details> <Details>{name}</Details>
<div
style={{
fontSize: '12px',
fontWeight: theme?.fontWeight.semiBold,
color:
theme?.palette.mode === 'light' ? theme?.palette.greyLight2 : theme?.palette.white,
}}
>
{name}
</div>
</Details>
</Author> </Author>
</OverviewItem>
); );
}; };
@ -132,33 +121,34 @@ const Package: React.FC<PackageInterface> = ({
time && ( time && (
<OverviewItem> <OverviewItem>
<StyledTime /> <StyledTime />
<Published>{t('package.published-on', { time: utils.formatDate(time) })}</Published> <Published title={utils.formatDate(time)}>
{utils.formatDateDistance(time)} {t('package.published-on', { time: utils.formatDateDistance(time) })}
</Published>
</OverviewItem> </OverviewItem>
); );
const renderHomePageLink = (): React.ReactNode => const renderHomePageLink = (): React.ReactNode =>
homepage && homepage &&
url.isURL(homepage) && ( url.isURL(homepage) && (
<Link external={true} to={homepage}> <LinkExternal to={homepage}>
<Tooltip aria-label={t('package.homepage')} title={t('package.visit-home-page')}> <Tooltip aria-label={t('package.homepage')} title={t('package.visit-home-page')}>
<IconButton aria-label={t('package.homepage')} size="large"> <IconButton aria-label={t('package.homepage')} size="large">
<HomeIcon /> <HomeIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Link> </LinkExternal>
); );
const renderBugsLink = (): React.ReactNode => const renderBugsLink = (): React.ReactNode =>
bugs?.url && bugs?.url &&
url.isURL(bugs.url) && ( url.isURL(bugs.url) && (
<Link external={true} to={bugs.url}> <LinkExternal to={bugs.url}>
<Tooltip aria-label={t('package.bugs')} title={t('package.open-an-issue')}> <Tooltip aria-label={t('package.bugs')} title={t('package.open-an-issue')}>
<IconButton aria-label={t('package.bugs')} size="large"> <IconButton aria-label={t('package.bugs')} size="large">
<BugReport /> <BugReport />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Link> </LinkExternal>
); );
const renderDownloadLink = (): React.ReactNode => const renderDownloadLink = (): React.ReactNode =>
@ -174,7 +164,11 @@ const Package: React.FC<PackageInterface> = ({
aria-label={t('package.download', { what: t('package.the-tar-file') })} aria-label={t('package.download', { what: t('package.the-tar-file') })}
title={t('package.tarball')} title={t('package.tarball')}
> >
<IconButton aria-label={t('package.download')} size="large"> <IconButton
aria-label={t('package.download')}
data-testid="download-tarball"
size="large"
>
{isLoading ? ( {isLoading ? (
<CircularProgress size={13}> <CircularProgress size={13}>
<DownloadIcon /> <DownloadIcon />
@ -191,7 +185,7 @@ const Package: React.FC<PackageInterface> = ({
return ( return (
<Grid container={true} item={true} xs={12}> <Grid container={true} item={true} xs={12}>
<Grid item={true} xs={11}> <Grid item={true} xs={11}>
<WrapperLink to={`/-/web/detail/${packageName}`}> <WrapperLink to={`${Route.DETAIL}${packageName}`}>
<PackageTitle className="package-title" data-testid="package-title"> <PackageTitle className="package-title" data-testid="package-title">
{packageName} {packageName}
</PackageTitle> </PackageTitle>
@ -213,15 +207,7 @@ const Package: React.FC<PackageInterface> = ({
); );
}; };
const renderSecondaryComponent = (): React.ReactNode => { const renderSecondaryComponent = (): React.ReactNode => <Description>{description}</Description>;
const tags = keywords.sort().map((keyword, index) => <Tag key={index}>{keyword}</Tag>);
return (
<>
<Description>{description}</Description>
{tags.length > 0 && <TagContainer>{tags}</TagContainer>}
</>
);
};
const renderPackageListItemText = (): React.ReactNode => ( const renderPackageListItemText = (): React.ReactNode => (
<PackageListItemText <PackageListItemText
@ -231,10 +217,21 @@ const Package: React.FC<PackageInterface> = ({
/> />
); );
const renderKeywords = (): React.ReactNode => (
<ListItem alignItems={'flex-start'} sx={{ py: 0, my: 0 }}>
<List sx={{ p: 0, my: 0 }}>
<KeywordListItems keywords={keywords} />
</List>
</ListItem>
);
return ( return (
<Wrapper className={'package'} data-testid="package-item-list"> <Wrapper className={'package'} data-testid="package-item-list">
<ListItem alignItems={'flex-start'}>{renderPackageListItemText()}</ListItem> <ListItem alignItems={'flex-start'} sx={{ mb: 0 }}>
<ListItem alignItems={'flex-start'}> {renderPackageListItemText()}
</ListItem>
{keywords && keywords?.length > 0 ? renderKeywords() : null}
<ListItem alignItems={'flex-start'} sx={{ mt: 0 }}>
{renderAuthorInfo()} {renderAuthorInfo()}
{renderVersionInfo()} {renderVersionInfo()}
{renderPublishedInfo()} {renderPublishedInfo()}
@ -248,8 +245,8 @@ const Package: React.FC<PackageInterface> = ({
export default Package; export default Package;
const iconStyle = ({ theme }: { theme: Theme }) => css` const iconStyle = ({ theme }: { theme: Theme }) => css`
margin: 2px 10px 0 0; margin: 0 10px 0 0;
fill: ${theme?.palette.mode === 'light' ? theme?.palette.greyLight2 : theme?.palette.white}; fill: ${theme?.palette.mode === 'light' ? theme?.palette.greyDark : theme?.palette.white};
`; `;
const StyledVersion = styled(Version)` const StyledVersion = styled(Version)`

@ -1,15 +0,0 @@
import React from 'react';
import { render } from '../../../test/test-react-testing-library';
import Tag from './Tag';
describe('<Tag /> component', () => {
test('should load the component in default state', () => {
const { container } = render(
<Tag>
<span>{'I am a child'}</span>
</Tag>
);
expect(container.firstChild).toMatchSnapshot();
});
});

@ -1,11 +0,0 @@
import React, { ReactNode } from 'react';
import { Wrapper } from './styles';
interface Props {
children: ReactNode;
}
const Tag: React.FC<Props> = ({ children }) => <Wrapper>{children}</Wrapper>;
export default Tag;

@ -1,21 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Tag /> component should load the component in default state 1`] = `
.emotion-0 {
vertical-align: middle;
line-height: 22px;
border-radius: 2px;
color: #485a3e;
background-color: #f3f4f2;
padding: 0.22rem 0.4rem;
margin: 8px 8px 0 0;
}
<span
class="emotion-0 emotion-1"
>
<span>
I am a child
</span>
</span>
`;

@ -1 +0,0 @@
export { default } from './Tag';

@ -1,11 +0,0 @@
import styled from '@emotion/styled';
export const Wrapper = styled('span')({
verticalAlign: 'middle',
lineHeight: '22px',
borderRadius: '2px',
color: '#485a3e',
backgroundColor: '#f3f4f2',
padding: '0.22rem 0.4rem',
margin: '8px 8px 0 0',
});

@ -11,8 +11,8 @@ import { Theme } from '../../';
export const OverviewItem = styled('span')<{ theme?: Theme }>(({ theme }) => ({ export const OverviewItem = styled('span')<{ theme?: Theme }>(({ theme }) => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
margin: '0 0 0 16px', margin: '0 20px 0 0',
color: theme?.palette.mode === 'light' ? theme?.palette.greyLight2 : theme?.palette.white, color: theme?.palette.mode === 'light' ? theme?.palette.greyDark2 : theme?.palette.white,
fontSize: 12, fontSize: 12,
[`@media (max-width: ${theme?.breakPoints.medium}px)`]: { [`@media (max-width: ${theme?.breakPoints.medium}px)`]: {
':nth-of-type(3)': { ':nth-of-type(3)': {
@ -26,10 +26,9 @@ export const OverviewItem = styled('span')<{ theme?: Theme }>(({ theme }) => ({
}, },
})); }));
export const Published = styled('span')<{ theme?: Theme }>(({ theme }) => ({ export const Published = styled('span')({
color: theme?.palette.mode === 'light' ? theme?.palette.greyLight2 : theme?.palette.white,
margin: '0 5px 0 0', margin: '0 5px 0 0',
})); });
export const Details = styled('span')({ export const Details = styled('span')({
marginLeft: '5px', marginLeft: '5px',
@ -59,6 +58,7 @@ export const PackageTitle = styled('span')<{ theme?: Theme }>(({ theme }) => ({
marginBottom: 12, marginBottom: 12,
color: theme?.palette.mode == 'dark' ? theme?.palette.dodgerBlue : theme?.palette.eclipse, color: theme?.palette.mode == 'dark' ? theme?.palette.dodgerBlue : theme?.palette.eclipse,
cursor: 'pointer', cursor: 'pointer',
textDecoration: 'none',
[`@media (max-width: ${theme?.breakPoints.small}px)`]: { [`@media (max-width: ${theme?.breakPoints.small}px)`]: {
fontSize: 14, fontSize: 14,
marginBottom: 8, marginBottom: 8,
@ -70,9 +70,9 @@ export const GridRightAligned = styled(Grid)({
}); });
export const Wrapper = styled(List)<{ theme?: Theme }>(({ theme }) => ({ export const Wrapper = styled(List)<{ theme?: Theme }>(({ theme }) => ({
':hover': { '&:hover': {
backgroundColor: backgroundColor:
theme?.palette?.type == 'dark' ? theme?.palette?.secondary.main : theme?.palette?.greyLight3, theme?.palette?.type == 'dark' ? theme?.palette?.secondary.main : theme?.palette?.greyLight2,
}, },
})); }));
@ -83,16 +83,6 @@ export const IconButton = styled(MuiIconButton)({
}, },
}); });
export const TagContainer = styled('span')<{ theme?: Theme }>(({ theme }) => ({
marginTop: 8,
marginBottom: 12,
display: 'flex',
flexWrap: 'wrap',
[`@media (max-width: ${theme?.breakPoints.medium}px)`]: {
display: 'none',
},
}));
export const PackageListItemText = styled(ListItemText)({ export const PackageListItemText = styled(ListItemText)({
paddingRight: 0, paddingRight: 0,
}); });

@ -40,6 +40,7 @@ describe('<PackageList /> component', () => {
name: 'xyz', name: 'xyz',
version: '1.1.0', version: '1.1.0',
description: 'xyz description', description: 'xyz description',
keywords: ['hello', 'mars'],
author: { name: 'Martin', avatar: 'test avatar' }, author: { name: 'Martin', avatar: 'test avatar' },
}, },
], ],

@ -0,0 +1,50 @@
import React from 'react';
import { fireEvent, render, screen } from '../../test/test-react-testing-library';
import { Developer } from '../../types/packageMeta';
import Person from './Person';
const mockPerson: Developer = {
name: 'John Doe',
email: 'john.doe@example.com',
avatar: 'https://example.com/avatar.jpg',
url: 'https://example.com/~johndoe',
};
const mockPackageName = 'test-package';
const mockVersion = '1.0.0';
const ComponentToBeRendered: React.FC<{ withText?: boolean }> = ({ withText = false }) => {
return (
<Person
packageName={mockPackageName}
person={mockPerson}
version={mockVersion}
withText={withText}
/>
);
};
describe('Person component', () => {
test('should render avatar', () => {
render(<ComponentToBeRendered />);
const avatar = screen.getByAltText(mockPerson.name);
expect(avatar).toBeInTheDocument();
// but not include text
expect(screen.queryByText(mockPerson.name)).not.toBeInTheDocument();
});
test('should render name when withText is true', () => {
render(<ComponentToBeRendered withText={true} />);
const name = screen.getByText(mockPerson.name);
expect(name).toBeInTheDocument();
});
test('should not render name when withText is false', async () => {
render(<ComponentToBeRendered />);
// hover over the avatar
fireEvent.mouseEnter(screen.getByTestId(mockPerson.name));
// wait for the tooltip to appear
await screen.findByTestId(mockPerson.name + '-tooltip');
});
});

@ -0,0 +1,45 @@
import { Typography } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Tooltip from '@mui/material/Tooltip';
import React from 'react';
import { Developer } from '../../types/packageMeta';
import LinkExternal from '../LinkExternal';
import PersonTooltip from './PersonTooltip';
import { getLink, getName } from './utils';
const Person: React.FC<{
person: Developer;
packageName: string;
version: string;
withText?: boolean;
}> = ({ person, packageName, version, withText = false }) => {
const link = getLink(person, packageName, version);
const avatarComponent = (
<Avatar alt={person.name} src={person.avatar} sx={{ width: 40, height: 40, ml: 0, mr: 1 }} />
);
return (
<>
<Tooltip
data-testid={person.name}
key={person.email}
title={<PersonTooltip person={person} />}
>
{link.length > 0 ? (
<LinkExternal to={link}>{avatarComponent}</LinkExternal>
) : (
avatarComponent
)}
</Tooltip>
{withText && (
<Typography sx={{ ml: 1 }} variant="subtitle2">
{getName(person.name)}
</Typography>
)}
</>
);
};
export default Person;

@ -0,0 +1,26 @@
import Typography from '@mui/material/Typography';
import React from 'react';
import { Developer } from '../../types/packageMeta';
import { url } from '../../utils';
import LinkExternal from '../LinkExternal';
const PersonTooltip: React.FC<{ person: Developer }> = ({ person }) => (
<Typography data-testid={person.name + '-tooltip'}>
{person.name}
{person.email && url.isEmail(person.email) && (
<LinkExternal to={`mailto:${person.email}`}>
<br />
{person.email}
</LinkExternal>
)}
{person.url && url.isURL(person.url) && (
<LinkExternal to={person.url}>
<br />
{person.url}
</LinkExternal>
)}
</Typography>
);
export default PersonTooltip;

@ -0,0 +1 @@
export { default } from './Person';

@ -0,0 +1,20 @@
import i18next from 'i18next';
import { Developer } from '../../types/packageMeta';
import { url } from '../../utils';
export function getLink(person: Developer, packageName: string, version: string): string {
return person.email && url.isEmail(person.email)
? `mailto:${person.email}?subject=${packageName} v${version}`
: person.url && url.isURL(person.url)
? person.url
: '';
}
export function getName(name?: string): string {
return !name
? i18next.t('author-unknown')
: name.toLowerCase() === 'anonymous'
? i18next.t('author-anonymous')
: name;
}

Some files were not shown because too many files have changed in this diff Show More