0
Fork 0
mirror of https://github.com/verdaccio/verdaccio.git synced 2024-12-16 21:56:25 -05: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

View file

@ -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

View file

@ -18,6 +18,12 @@ storage: ./storage
# https://verdaccio.org/docs/webui
web:
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
# gravatar: false
# by default packages are ordercer ascendant (asc|desc)
@ -35,6 +41,7 @@ web:
# showSearch: true
# showRaw: true
# showDownloadTarball: true
# showUplinks: true
# HTML tags injected after manifest <scripts/>
# scriptsBodyAfter:
# - '<script type="text/javascript" src="https://my.company.com/customJS.min.js"></script>'

View file

@ -21,6 +21,12 @@ plugins: /verdaccio/plugins
# https://verdaccio.org/docs/webui
web:
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
# gravatar: false
# By default packages are ordered ascendant (asc|desc)
@ -38,6 +44,7 @@ web:
# showSearch: true
# showRaw: true
# showDownloadTarball: true
# showUplinks: true
# HTML tags injected after manifest <scripts/>
# scriptsBodyAfter:
# - '<script type="text/javascript" src="https://my.company.com/customJS.min.js"></script>'

View file

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

View file

@ -42,34 +42,46 @@ export function renderWebMiddleware(config, tokenMiddleware, pluginOptions) {
res.sendFile(file, sendFileCallback(next));
});
// check the origin of the logo
if (config?.web?.logo && !isURLhasValidProtocol(config?.web?.logo)) {
// URI related to a local file
const absoluteLocalFile = path.posix.resolve(config.web.logo);
debug('serve local logo %s', absoluteLocalFile);
try {
// TODO: replace existsSync by async alternative
if (
fs.existsSync(absoluteLocalFile) &&
typeof fs.accessSync(absoluteLocalFile, fs.constants.R_OK) === 'undefined'
) {
// Note: `path.join` will break on Windows, because it transforms `/` to `\`
// Use POSIX version `path.posix.join` instead.
config.web.logo = path.posix.join('/-/static/', path.basename(config.web.logo));
router.get(config.web.logo, function (_req, res, next) {
// @ts-ignore
debug('serve custom logo web:%s - local:%s', config.web.logo, absoluteLocalFile);
res.sendFile(absoluteLocalFile, sendFileCallback(next));
});
debug('enabled custom logo %s', config.web.logo);
} else {
config.web.logo = undefined;
function renderLogo(logo: string | undefined): string | undefined {
// check the origin of the logo
if (logo && !isURLhasValidProtocol(logo)) {
// URI related to a local file
const absoluteLocalFile = path.posix.resolve(logo);
debug('serve local logo %s', absoluteLocalFile);
try {
// TODO: replace existsSync by async alternative
if (
fs.existsSync(absoluteLocalFile) &&
typeof fs.accessSync(absoluteLocalFile, fs.constants.R_OK) === 'undefined'
) {
// Note: `path.join` will break on Windows, because it transforms `/` to `\`
// Use POSIX version `path.posix.join` instead.
logo = path.posix.join('/-/static/', path.basename(logo));
router.get(logo, function (_req, res, next) {
// @ts-ignore
debug('serve custom logo web:%s - local:%s', logo, absoluteLocalFile);
res.sendFile(absoluteLocalFile, sendFileCallback(next));
});
debug('enabled custom logo %s', logo);
} else {
logo = undefined;
debug(`web logo is wrong, path ${absoluteLocalFile} does not exist or is not readable`);
}
} catch {
logo = undefined;
debug(`web logo is wrong, path ${absoluteLocalFile} does not exist or is not readable`);
}
} catch {
config.web.logo = undefined;
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) {

View file

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

View file

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

View file

@ -79,6 +79,13 @@ describe('test web server', () => {
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 () => {
const {
window: { __VERDACCIO_BASENAME_UI_OPTIONS },

View file

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

View file

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

View file

@ -60,7 +60,7 @@
"react/jsx-curly-brace-presence": ["warn", { "props": "ignore", "children": "ignore" }],
"react/jsx-pascal-case": ["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/no-string-refs": ["error"],
"react/no-danger-with-children": ["error"],

View file

@ -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>';

View file

@ -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>

View file

@ -1,3 +1,4 @@
import fs from 'fs';
import { rest } from 'msw';
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) => {
return res(
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>
`)
);
return res(ctx.text(require('./api/storybook-readme')()));
}),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/jquery', (req, res, ctx) => {
return res(ctx.json(require('./api/jquery-sidebar.json')));
}),
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) => {
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) => {
return res(ctx.status(404));
return res(ctx.status(404)); // not found
}),
rest.get('http://localhost:9000/-/verdaccio/data/sidebar/glob', (req, res, ctx) => {
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) => {
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 }>(
'http://localhost:9000/-/verdaccio/sec/login',

View file

@ -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'));
});
});

View file

@ -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');
});
});

View file

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

View file

@ -1,28 +1,22 @@
import React from 'react';
import { store } from '../../store/store';
import {
cleanup,
fireEvent,
renderWithStore,
screen,
waitFor,
} from '../../test/test-react-testing-library';
import { cleanup, fireEvent, renderWithStore, screen } from '../../test/test-react-testing-library';
import ActionBar from './ActionBar';
const defaultPackageMeta = {
_uplinks: {},
latest: {
name: 'verdaccio-ui/local-storage',
version: '8.0.1-next.1',
name: 'verdaccio',
version: '1.0.0',
dist: {
fileCount: 0,
unpackedSize: 0,
tarball: 'http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz',
fileCount: 1,
unpackedSize: 171,
tarball: 'http://localhost:9000/verdaccio/-/verdaccio-1.0.0.tgz',
},
homepage: 'https://verdaccio.org',
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();
});
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', () => {
const packageMeta = {
...defaultPackageMeta,
@ -64,6 +64,14 @@ describe('<ActionBar /> component', () => {
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', () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store);
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 () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store);
expect(screen.queryByTestId('rawViewer--dialog')).toBeFalsy();
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', () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={true} />, store);
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={false} />, store);
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', () => {
renderWithStore(<ActionBar packageMeta={defaultPackageMeta} showRaw={false} />, store);
expect(screen.queryByLabelText('action-bar-action.raw')).toBeFalsy();

View file

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

View file

@ -12,11 +12,14 @@ import { useDispatch, useSelector } from 'react-redux';
import { Theme } from '../../Theme';
import { Dispatch, RootState } from '../../store/store';
import { Link } from '../Link';
import LinkExternal from '../LinkExternal';
export const Fab = styled(FabMUI)<{ theme?: Theme }>(({ theme }) => ({
backgroundColor:
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,
}));
@ -42,21 +45,21 @@ const ActionBarAction: React.FC<ActionBarActionProps> = ({ type, link, action })
case 'VISIT_HOMEPAGE':
return (
<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">
<HomeIcon />
</Fab>
</Link>
</LinkExternal>
</Tooltip>
);
case 'OPEN_AN_ISSUE':
return (
<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">
<BugReportIcon />
</Fab>
</Link>
</LinkExternal>
</Tooltip>
);
case 'DOWNLOAD_TARBALL':

View file

@ -1,6 +1,6 @@
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 Authors from './Author';
@ -66,4 +66,10 @@ describe('<Author /> component', () => {
const wrapper = render(withAuthorComponent(packageMeta));
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();
});
});

View file

@ -1,35 +1,13 @@
import { Typography } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import List from '@mui/material/List';
import { useTheme } from '@mui/styles';
import i18next from 'i18next';
import React, { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { url } from '../../utils';
import Person from '../Person';
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 { t } = useTranslation();
const theme = useTheme();
if (!packageMeta) {
return null;
}
@ -40,26 +18,10 @@ const Author: FC<{ packageMeta }> = ({ packageMeta }) => {
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 (
<List subheader={<StyledText variant={'subtitle1'}>{t('sidebar.author.title')}</StyledText>}>
<AuthorListItem>
{!email || !url.isEmail(email) ? (
avatarComponent
) : (
<a href={`mailto:${email}?subject=${packageName}@${version}`} target={'_top'}>
{avatarComponent}
</a>
)}
{name && <Typography variant="subtitle2">{getAuthorName(name)}</Typography>}
<AuthorListItem sx={{ my: 1 }}>
<Person packageName={packageName} person={author} version={version} withText={true} />
</AuthorListItem>
</List>
);

View file

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

View file

@ -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);
});
});

View file

@ -5,17 +5,6 @@ export const copyToClipBoardUtility =
(event: SyntheticEvent<HTMLElement>): void => {
event.preventDefault();
const node = document.createElement('div');
node.innerText = 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);
}
// document.execCommand is deprecated
navigator.clipboard.writeText(str);
};

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
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 { DeveloperType } from './Title';
describe('test Developers', () => {
afterEach(() => {
@ -63,7 +63,7 @@ describe('test Developers', () => {
expect(wrapper).toMatchSnapshot();
});
test('should test onClick the component avatar', () => {
test('should show only up to max items', () => {
const packageMeta = {
latest: {
packageName: 'foo',
@ -72,6 +72,7 @@ describe('test Developers', () => {
{
name: 'dmethvin',
email: 'test@gmail.com',
url: 'https://example.com/',
},
{
name: 'dmethvin2',
@ -89,14 +90,40 @@ describe('test Developers', () => {
<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();
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();
expect(wrapper.getByLabelText(packageMeta.latest.contributors[1].name)).toBeInTheDocument();
test('renders only the first six contributors when there are more than six', () => {
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();
});
});

View file

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

View file

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

View file

@ -26,10 +26,26 @@ exports[`test Developers should render the component for contributors with items
}
.emotion-3>* {
margin: 5px;
margin-right: 5px;
}
.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;
display: -webkit-box;
display: -webkit-flex;
@ -59,9 +75,13 @@ exports[`test Developers should render the component for contributors with items
user-select: none;
color: #f4f4f4;
background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
}
.emotion-5 {
.emotion-6 {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
@ -90,40 +110,54 @@ exports[`test Developers should render the component for contributors with items
<div
class="emotion-2 MuiBox-root emotion-3"
>
<div
aria-label="dmethvin"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<div
aria-label="mgol"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
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"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
</div>
</div>
</body>,
@ -150,10 +184,26 @@ exports[`test Developers should render the component for contributors with items
}
.emotion-3>* {
margin: 5px;
margin-right: 5px;
}
.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;
display: -webkit-box;
display: -webkit-flex;
@ -183,9 +233,13 @@ exports[`test Developers should render the component for contributors with items
user-select: none;
color: #f4f4f4;
background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
}
.emotion-5 {
.emotion-6 {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
@ -213,40 +267,54 @@ exports[`test Developers should render the component for contributors with items
<div
class="emotion-2 MuiBox-root emotion-3"
>
<div
aria-label="dmethvin"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<div
aria-label="mgol"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
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"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
</div>
</div>,
"debug": [Function],
@ -329,10 +397,26 @@ exports[`test Developers should render the component for maintainers with items
}
.emotion-3>* {
margin: 5px;
margin-right: 5px;
}
.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;
display: -webkit-box;
display: -webkit-flex;
@ -362,9 +446,13 @@ exports[`test Developers should render the component for maintainers with items
user-select: none;
color: #f4f4f4;
background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
}
.emotion-5 {
.emotion-6 {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
@ -393,40 +481,54 @@ exports[`test Developers should render the component for maintainers with items
<div
class="emotion-2 MuiBox-root emotion-3"
>
<div
aria-label="dmethvin"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<div
aria-label="mgol"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
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"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
</div>
</div>
</body>,
@ -453,10 +555,26 @@ exports[`test Developers should render the component for maintainers with items
}
.emotion-3>* {
margin: 5px;
margin-right: 5px;
}
.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;
display: -webkit-box;
display: -webkit-flex;
@ -486,9 +604,13 @@ exports[`test Developers should render the component for maintainers with items
user-select: none;
color: #f4f4f4;
background-color: #bdbdbd;
width: 40px;
height: 40px;
margin-left: 0px;
margin-right: 8px;
}
.emotion-5 {
.emotion-6 {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
@ -516,40 +638,54 @@ exports[`test Developers should render the component for maintainers with items
<div
class="emotion-2 MuiBox-root emotion-3"
>
<div
aria-label="dmethvin"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
data-mui-internal-clone-element="true"
data-testid="dmethvin"
href="mailto:test@gmail.com?subject=undefined v1.0.0"
rel="noopener noreferrer"
target="_blank"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<div
aria-label="mgol"
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-4"
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
<a
class="MuiTypography-root MuiTypography-caption MuiLink-root MuiLink-underlineHover emotion-4"
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"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-5"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
<div
class="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault emotion-5"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiAvatar-fallback emotion-6"
data-testid="PersonIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
</div>
</a>
</div>
</div>,
"debug": [Function],

View file

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

View file

@ -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,
}));

View file

@ -33,7 +33,7 @@ describe('<Dist /> component', () => {
expect(getByText('sidebar.distribution.size')).toBeInTheDocument();
expect(getByText('sidebar.distribution.size')).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', () => {

View file

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

View file

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

View file

@ -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')
);
}

View file

@ -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');
});

View file

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

View file

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

View file

@ -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();
});
});

View file

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

View file

@ -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();
});
});

View file

@ -54,7 +54,7 @@ const HeaderInfoDialog: React.FC<Props> = ({
<RegistryInfoDialog onClose={onCloseDialog} open={isOpen} title={dialogTitle}>
<Box sx={{ width: '100%' }}>
<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.map((item, index) => {
return (
@ -68,7 +68,7 @@ const HeaderInfoDialog: React.FC<Props> = ({
{tabPanels
? tabPanels.map((item, index) => {
return (
<TabPanel index={index} key={item.key} value={value}>
<TabPanel index={index} key={index} value={value}>
{item.element}
</TabPanel>
);

View file

@ -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');
});
});

View file

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

View file

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react';
const icon = require('./es6modules.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
const icon = require('./es6module.svg');
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" />;
}

View file

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

View file

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

View file

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

View file

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -9,7 +9,8 @@ const FileBinary = React.forwardRef(function FileBinary(
ref: React.Ref<SVGSVGElement>
) {
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" />
</SvgIcon>
);

View file

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

View file

@ -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');
});
});

View file

@ -6,7 +6,8 @@ type Props = React.ComponentProps<typeof SvgIcon>;
const Law = React.forwardRef(function Law(props: Props, ref: React.Ref<SVGSVGElement>) {
return (
<SvgIcon {...props} ref={ref}>
// eslint-disable-next-line verdaccio/jsx-spread
<SvgIcon viewBox="0 0 14 16" {...props} ref={ref}>
<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"
fillRule="evenodd"

View file

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react';
const icon = require('./npm.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
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" />;
}

View file

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react';
const icon = require('./pnpm.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
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" />;
}

View file

@ -1,13 +1,7 @@
import styled from '@emotion/styled';
import { Theme } from '@mui/material';
import React from 'react';
const icon = require('./yarn.svg');
const ImgIcon = styled.img<{ theme?: Theme }>(({ theme }) => ({
marginLeft: theme?.spacing(1),
}));
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" />;
}

View file

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

View file

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

View file

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

View file

@ -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;

View file

@ -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'],
},
}}
/>
);

View file

@ -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();
});
});

View file

@ -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;

View file

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

View file

@ -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();
});
});

View file

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

View file

@ -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>
`;

View file

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

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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>
`;

View file

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

View file

@ -1,12 +1,15 @@
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';
describe('<Loading /> component', () => {
test('should render the component in default state', () => {
render(<Loading />);
screen.debug();
expect(screen.getByTestId('loading')).toBeInTheDocument();
test('should render the component in default state', async () => {
act(() => {
render(<Loading />);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
);

View file

@ -1,11 +1,18 @@
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';
describe('<Logo /> component', () => {
test('should render the component in default state', () => {
const { container } = render(<Logo />);
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();
});
});

View file

@ -1,4 +1,5 @@
import styled from '@emotion/styled';
import { useTheme } from '@mui/styles';
import React from 'react';
import { Theme, useConfig } from '../../';
@ -22,18 +23,33 @@ interface Props {
onClick?: () => void;
className?: string;
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 theme = useTheme();
if (!isDefault && configOptions?.logo) {
const logoSrc =
theme?.palette.mode === 'dark' && configOptions.logoDark
? configOptions.logoDark
: configOptions.logo;
return (
<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>
);
}
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;

View file

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

View file

@ -2,7 +2,13 @@ import React from 'react';
import { MemoryRouter } from 'react-router';
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';
/**
@ -11,29 +17,36 @@ import Package from './Package';
*/
const dateOneMonthAgo = (): Date => new Date(1544377770747);
const props = {
name: 'verdaccio',
version: '1.0.0',
time: String(dateOneMonthAgo()),
license: 'MIT',
description: 'Private NPM repository',
author: {
name: 'Sam',
},
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 props = {
name: 'verdaccio',
version: '1.0.0',
time: String(dateOneMonthAgo()),
license: 'MIT',
description: 'Private NPM repository',
author: {
name: 'Sam',
},
keywords: ['verdaccio'],
};
const wrapper = renderWithStore(
<MemoryRouter>
<Package
author={props.author}
description={props.description}
dist={props.dist}
keywords={props.keywords}
license={props.license}
name={props.name}
time={props.time}
@ -50,5 +63,25 @@ describe('<Package /> component', () => {
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));
});
});

View file

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

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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>
`;

View file

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

View file

@ -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',
});

View file

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

View file

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

View file

@ -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');
});
});

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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