1
0
mirror of https://github.com/SomboChea/ui synced 2026-01-12 22:25:52 +07:00

Compare commits

..

7 Commits

Author SHA1 Message Date
Juan Picado @jotadeveloper
45f76bfc2c Merge branch 'master' into juanpicado-patch-1 2019-11-23 13:48:11 +01:00
Juan Picado @jotadeveloper
b03e6d6c27 Merge branch 'master' into juanpicado-patch-1 2019-09-13 23:52:33 -07:00
Juan Picado @jotadeveloper
cdfbde1df1 Merge branch 'master' into juanpicado-patch-1 2019-08-12 07:46:00 +02:00
Juan Picado @jotadeveloper
521e11a453 Merge branch 'master' into juanpicado-patch-1 2019-08-04 22:15:22 +02:00
Juan Picado @jotadeveloper
fd4d7037ed Merge branch '4.x-master' into juanpicado-patch-1 2019-06-26 09:58:51 +02:00
Juan Picado @jotadeveloper
c918518f69 Merge branch '4.x-master' into juanpicado-patch-1 2019-06-03 21:21:23 +02:00
Juan Picado @jotadeveloper
5ff4b8a184 feat: add CONTRIBUTING.md 2019-05-26 18:53:40 +02:00
100 changed files with 2680 additions and 9619 deletions

View File

@@ -1,8 +1,4 @@
{
"presets": [["@verdaccio"]],
"plugins": [
"emotion",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
"plugins": ["emotion"]
}

View File

@@ -45,8 +45,11 @@
}
}
],
"@typescript-eslint/explicit-function-return-type": 0,
"react/display-name": 0,
"@typescript-eslint/explicit-function-return-type": ["warn",
{
"allowExpressions": true,
"allowTypedFunctionExpressions": true
}],
"react/no-deprecated": 1,
"react/jsx-no-target-blank": 1,
"react/destructuring-assignment": ["error", "always"],
@@ -72,7 +75,7 @@
"arrow": "parens",
"condition": "parens",
"logical": "parens",
"prop": "ignore"
"prop": "parens"
}],
"react/jsx-boolean-value": ["error", "always"],
"react/jsx-closing-tag-location": ["error"],
@@ -83,7 +86,7 @@
"react/jsx-indent": ["error", 2],
"react/jsx-indent-props": ["error", 2],
"react/jsx-key": ["error"],
"react/jsx-max-depth":["error", { "max": 5}],
"react/jsx-max-depth": ["error", { "max": 2}],
"react/jsx-max-props-per-line": ["error", {"maximum": 3, "when": "multiline" }],
"react/jsx-no-bind": ["error"],
"react/jsx-no-comment-textnodes": ["error"],

View File

@@ -7,4 +7,4 @@
"typescriptreact"
],
"typescript.tsdk": "node_modules/typescript/lib"
}
}

View File

@@ -1,2 +1 @@
save-prefix ""
registry "https://registry.verdaccio.org"

View File

@@ -2,34 +2,6 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.3.8](https://github.com/verdaccio/ui/compare/v0.3.7...v0.3.8) (2019-12-14)
### Features
* login Dialog Component - Replaced class by func. comp + added react-hook-form ([#341](https://github.com/verdaccio/ui/issues/341)) ([42d3bb8](https://github.com/verdaccio/ui/commit/42d3bb8508c666c28250432ada734d58ccb0eca8))
### Bug Fixes
* formatDate ([#308](https://github.com/verdaccio/ui/issues/308)) ([33f873a](https://github.com/verdaccio/ui/commit/33f873a8c78e419a36e3a29f7ea216714172b174))
* removed deade import ([#346](https://github.com/verdaccio/ui/issues/346)) ([ae617a5](https://github.com/verdaccio/ui/commit/ae617a5c04ad1b82309d36d3bdcf6b6b6fd925d0))
* updated actionbar snap ([#340](https://github.com/verdaccio/ui/issues/340)) ([09b831a](https://github.com/verdaccio/ui/commit/09b831a40d4e82a122f8fae3e45bdd161a3281bb))
### [0.3.7](https://github.com/verdaccio/ui/compare/v0.3.6...v0.3.7) (2019-11-24)
### Features
* Added Theme and migrate to emotion@10.x 🚀 ([#286](https://github.com/verdaccio/ui/issues/286)) ([111f0c5](https://github.com/verdaccio/ui/commit/111f0c50e5053202ca55fe4f3f28dd30e4932240))
### Bug Fixes
* **#300:** correctly reference registry url from options ([ee74474](https://github.com/verdaccio/ui/commit/ee74474811eb609072e1678bcb90db33756dcf38)), closes [#300](https://github.com/verdaccio/ui/issues/300)
* restore lint-staged@8.2.1 ([dbaa0c4](https://github.com/verdaccio/ui/commit/dbaa0c43b8104b350e4907387f89d4e9e719741f))
* update snapshots ([fd306de](https://github.com/verdaccio/ui/commit/fd306def9535d9168dc79ab020ec288a4d5df1a8))
### [0.3.6](https://github.com/verdaccio/ui/compare/v0.3.5...v0.3.6) (2019-11-08)
### [0.3.5](https://github.com/verdaccio/ui/compare/v0.3.4...v0.3.5) (2019-11-07)

3
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,3 @@
# Contributing Guidelines
// TODO

View File

@@ -6,7 +6,6 @@ import 'raf/polyfill';
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import { GlobalWithFetchMock } from 'jest-fetch-mock';
import 'mutationobserver-shim';
// @ts-ignore : Only a void function can be called with the 'new' keyword
configure({ adapter: new Adapter() });

View File

@@ -0,0 +1,5 @@
{
"rules": {
"@typescript-eslint/explicit-function-return-type": 0
}
}

View File

@@ -3,19 +3,12 @@
*/
import { Base64 } from 'js-base64';
import dayjs from 'dayjs';
import addHours from 'date-fns/addHours';
export function generateTokenWithTimeRange(amount = 0) {
export function generateTokenWithTimeRange(limit = 0) {
const payload = {
username: 'verdaccio',
exp: Number.parseInt(
String(
dayjs(new Date())
.add(amount, 'hour')
.valueOf() / 1000
),
10
),
exp: Number.parseInt(String(addHours(new Date(), limit).getTime() / 1000), 10),
};
return `xxxxxx.${Base64.encode(JSON.stringify(payload))}.xxxxxx`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@verdaccio/ui-theme",
"version": "0.3.8",
"version": "0.3.6",
"description": "Verdaccio User Interface",
"author": {
"name": "Verdaccio Core Team",
@@ -13,59 +13,56 @@
"homepage": "https://verdaccio.org",
"main": "index.js",
"devDependencies": {
"@babel/plugin-proposal-nullish-coalescing-operator": "7.7.4",
"@babel/plugin-proposal-optional-chaining": "7.7.5",
"@commitlint/cli": "8.2.0",
"@commitlint/config-conventional": "8.2.0",
"@emotion/core": "10.0.22",
"@emotion/styled": "10.0.23",
"@material-ui/core": "4.7.2",
"@material-ui/core": "4.6.1",
"@material-ui/icons": "4.5.1",
"@octokit/rest": "16.35.0",
"@testing-library/jest-dom": "4.2.4",
"@testing-library/react": "9.3.3",
"@testing-library/react": "9.3.2",
"@types/autosuggest-highlight": "3.1.0",
"@types/enzyme": "3.10.4",
"@types/enzyme": "3.10.3",
"@types/jest": "24.0.23",
"@types/js-base64": "2.3.1",
"@types/lodash": "4.14.149",
"@types/node": "12.12.17",
"@types/react": "16.9.16",
"@types/node": "12.12.11",
"@types/react": "16.9.11",
"@types/react-autosuggest": "9.3.13",
"@types/react-dom": "16.9.4",
"@types/react-router-dom": "5.1.3",
"@types/react-router-dom": "5.1.2",
"@types/request": "2.48.3",
"@types/validator": "12.0.1",
"@types/validator": "12.0.0",
"@types/webpack-env": "1.14.1",
"@typescript-eslint/parser": "2.11.0",
"@verdaccio/babel-preset": "8.4.2",
"@verdaccio/commons-api": "8.4.2",
"@verdaccio/eslint-config": "8.4.2",
"@verdaccio/types": "8.4.2",
"@typescript-eslint/parser": "2.8.0",
"@verdaccio/babel-preset": "8.2.0",
"@verdaccio/commons-api": "8.3.0",
"@verdaccio/eslint-config": "8.2.0",
"@verdaccio/types": "8.3.0",
"autosuggest-highlight": "3.1.1",
"babel-loader": "8.0.6",
"bundlesize": "0.18.0",
"codeceptjs": "2.3.6",
"codeceptjs": "2.3.5",
"codecov": "3.6.1",
"concurrently": "5.0.1",
"concurrently": "5.0.0",
"cross-env": "6.0.3",
"css-loader": "3.3.0",
"dayjs": "1.8.17",
"css-loader": "3.2.0",
"date-fns": "2.8.1",
"detect-secrets": "1.0.5",
"emotion": "10.0.23",
"emotion-theming": "10.0.19",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.1",
"enzyme-to-json": "3.4.3",
"eslint": "6.7.2",
"eslint": "6.6.0",
"eslint-plugin-codeceptjs": "1.2.0",
"eslint-plugin-import": "2.19.1",
"eslint-plugin-import": "2.18.2",
"eslint-plugin-jsx-a11y": "6.2.3",
"eslint-plugin-prettier": "3.1.1",
"eslint-plugin-react": "7.17.0",
"eslint-plugin-react": "7.16.0",
"eslint-plugin-react-hooks": "2.3.0",
"eslint-plugin-verdaccio": "8.4.2",
"file-loader": "5.0.2",
"eslint-plugin-verdaccio": "8.2.0",
"file-loader": "4.3.0",
"friendly-errors-webpack-plugin": "1.7.0",
"get-stdin": "7.0.0",
"github-markdown-css": "3.0.1",
@@ -74,19 +71,18 @@
"identity-obj-proxy": "3.0.0",
"in-publish": "2.0.0",
"jest": "24.9.0",
"jest-emotion": "10.0.26",
"jest-emotion": "10.0.17",
"jest-environment-jsdom": "24.9.0",
"jest-environment-jsdom-global": "1.2.0",
"jest-environment-node": "24.9.0",
"jest-fetch-mock": "2.1.2",
"js-base64": "2.5.1",
"js-yaml": "3.13.1",
"lint-staged": "9.5.0",
"lint-staged": "8.2.1",
"localstorage-memory": "1.0.3",
"lockfile-lint": "3.0.3",
"lockfile-lint": "2.2.0",
"lodash": "^4.17.15",
"mini-css-extract-plugin": "0.8.0",
"mutationobserver-shim": "0.3.3",
"node-mocks-http": "1.8.0",
"normalize.css": "8.0.1",
"optimize-css-assets-webpack-plugin": "5.0.3",
@@ -97,7 +93,6 @@
"react": "16.12.0",
"react-autosuggest": "9.4.3",
"react-dom": "16.12.0",
"react-hook-form": "3.28.15",
"react-hot-loader": "4.12.18",
"react-router-dom": "5.1.2",
"request": "2.88.0",
@@ -105,21 +100,21 @@
"rimraf": "3.0.0",
"source-map-loader": "0.2.4",
"standard-version": "7.0.1",
"style-loader": "1.0.1",
"style-loader": "1.0.0",
"stylelint": "12.0.0",
"stylelint-config-recommended": "3.0.0",
"stylelint-config-styled-components": "0.1.1",
"stylelint-processor-styled-components": "1.9.0",
"stylelint-webpack-plugin": "1.1.2",
"stylelint-processor-styled-components": "1.8.0",
"stylelint-webpack-plugin": "1.1.0",
"supertest": "4.0.2",
"typeface-roboto": "0.0.75",
"typescript": "3.7.3",
"typescript": "3.7.2",
"uglifyjs-webpack-plugin": "2.2.0",
"url-loader": "3.0.0",
"url-loader": "2.3.0",
"validator": "12.1.0",
"verdaccio": "4.3.5",
"verdaccio-auth-memory": "8.4.2",
"verdaccio-memory": "8.4.2",
"verdaccio-auth-memory": "8.3.0",
"verdaccio-memory": "8.3.0",
"wait-on": "3.3.0",
"webpack": "4.41.2",
"webpack-bundle-analyzer": "3.6.0",
@@ -138,7 +133,7 @@
"bundlesize": [
{
"path": "./static/vendors.*.js",
"maxSize": "185 kB"
"maxSize": "180 kB"
},
{
"path": "./static/main.*.js",
@@ -186,23 +181,29 @@
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run verdaccio:server\""
},
"engines": {
"node": ">= 10.13.0",
"node": ">=8",
"npm": ">=5"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged --relative",
"pre-commit": "lint-staged",
"commit-msg": "commitlint -e $GIT_PARAMS"
}
},
"lint-staged": {
"*.{js,tsx,ts}": [
"eslint . --ext .js,.ts,.tsx",
"prettier --write"
],
"*": [
"detect-secrets-launcher --baseline .secrets-baseline",
"git add"
"relative": true,
"linters": {
"*.{js,tsx,ts}": [
"eslint . --ext .js,.ts,.tsx",
"prettier --write"
],
"*": [
"detect-secrets-launcher --baseline .secrets-baseline",
"git add"
]
},
"ignore": [
"*.json"
]
},
"license": "MIT",
@@ -215,5 +216,6 @@
"type": "opencollective",
"url": "https://opencollective.com/verdaccio",
"logo": "https://opencollective.com/verdaccio/logo.txt"
}
},
"dependencies": {}
}

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { ReactWrapper } from 'enzyme';
import { render, waitForElement, fireEvent } from '../utils/test-react-testing-library';
import { mount } from '../utils/test-enzyme';
import storage from '../utils/storage';
// eslint-disable-next-line jest/no-mocks-import
import { generateTokenWithTimeRange } from '../../jest/unit/components/__mocks__/token';
import App from './App';
import { AppProps } from './AppContext';
jest.mock('../utils/storage', () => {
class LocalStorageMock {
@@ -30,75 +31,66 @@ jest.mock('../utils/storage', () => {
});
jest.mock('../utils/api', () => ({
// eslint-disable-next-line jest/no-mocks-import
request: require('../../jest/unit/components/__mocks__/api').default.request,
}));
/* eslint-disable react/jsx-no-bind*/
describe('<App />', () => {
test('should display the Loading component at the beginning ', () => {
const { container, queryByTestId } = render(<App />);
describe('App', () => {
let wrapper: ReactWrapper<{}, AppProps, App>;
expect(container.firstChild).toMatchSnapshot();
expect(queryByTestId('loading')).toBeTruthy();
beforeEach(() => {
wrapper = mount(<App />);
});
test('should display the Header component ', async () => {
const { container, queryByTestId } = render(<App />);
expect(container.firstChild).toMatchSnapshot();
expect(queryByTestId('loading')).toBeTruthy();
// wait for the Header component appearance and return the element
const headerElement = await waitForElement(() => queryByTestId('header'));
expect(headerElement).toBeTruthy();
});
test('handleLogout - logouts the user and clear localstorage', async () => {
storage.setItem('username', 'verdaccio');
storage.setItem('token', generateTokenWithTimeRange(24));
const { queryByTestId } = render(<App />);
// wait for the Account's circle element component appearance and return the element
const accountCircleElement = await waitForElement(() => queryByTestId('header--menu-accountcircle'));
expect(accountCircleElement).toBeTruthy();
if (accountCircleElement) {
fireEvent.click(accountCircleElement);
// wait for the Button's logout element component appearance and return the element
const buttonLogoutElement = await waitForElement(() => queryByTestId('header--button-logout'));
expect(buttonLogoutElement).toBeTruthy();
if (buttonLogoutElement) {
fireEvent.click(buttonLogoutElement);
expect(queryByTestId('greetings-label')).toBeFalsy();
}
}
test('toggleLoginModal: should toggle the value in state', () => {
const { handleToggleLoginModal } = wrapper.instance();
expect(wrapper.state().showLoginModal).toBeFalsy();
handleToggleLoginModal();
expect(wrapper.state('showLoginModal')).toBeTruthy();
expect(wrapper.state('error')).toEqual(undefined);
});
test('isUserAlreadyLoggedIn: token already available in storage', async () => {
storage.setItem('username', 'verdaccio');
storage.setItem('token', generateTokenWithTimeRange(24));
const { isUserAlreadyLoggedIn } = wrapper.instance();
const { queryByTestId, queryAllByText } = render(<App />);
isUserAlreadyLoggedIn();
// wait for the Account's circle element component appearance and return the element
const accountCircleElement = await waitForElement(() => queryByTestId('header--menu-accountcircle'));
expect(accountCircleElement).toBeTruthy();
expect(wrapper.state('user').username).toEqual('verdaccio');
});
if (accountCircleElement) {
fireEvent.click(accountCircleElement);
test('handleLogout - logouts the user and clear localstorage', async () => {
const { handleLogout } = wrapper.instance();
storage.setItem('username', 'verdaccio');
storage.setItem('token', 'xxxx.TOKEN.xxxx');
// wait for the Greeting's label element component appearance and return the element
const greetingsLabelElement = await waitForElement(() => queryByTestId('greetings-label'));
expect(greetingsLabelElement).toBeTruthy();
await handleLogout();
expect(wrapper.state('user')).toEqual({});
expect(wrapper.state('isUserLoggedIn')).toBeFalsy();
});
if (greetingsLabelElement) {
expect(queryAllByText('verdaccio')).toBeTruthy();
}
}
test('handleDoLogin - login the user successfully', async () => {
const { handleDoLogin } = wrapper.instance();
await handleDoLogin('sam', '1234');
const result = {
username: 'sam',
};
expect(wrapper.state('isUserLoggedIn')).toBeTruthy();
expect(wrapper.state('showLoginModal')).toBeFalsy();
expect(storage.getItem('username')).toEqual('sam');
expect(storage.getItem('token')).toEqual('TEST_TOKEN');
expect(wrapper.state('user')).toEqual(result);
});
test('handleDoLogin - authentication failure', async () => {
const { handleDoLogin } = wrapper.instance();
await handleDoLogin('sam', '12345');
const result = {
description: 'bad username/password, access denied',
title: 'Unable to login',
type: 'error',
};
expect(wrapper.state('user')).toEqual({});
expect(wrapper.state('error')).toEqual(result);
});
});

View File

@@ -1,104 +1,184 @@
import React, { useState, useEffect } from 'react';
import styled from '@emotion/styled';
import React, { Component, ReactElement } from 'react';
import isNil from 'lodash/isNil';
import { Router } from 'react-router-dom';
import storage from '../utils/storage';
import { isTokenExpire } from '../utils/login';
import API from '../utils/api';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Box from '../muiComponents/Box';
import { makeLogin, isTokenExpire } from '../utils/login';
import Loading from '../components/Loading';
import StyleBaseline from '../design-tokens/StyleBaseline';
import { Theme } from '../design-tokens/theme';
import LoginModal from '../components/Login';
import Header from '../components/Header';
import { Container, Content } from '../components/Layout';
import API from '../utils/api';
import Footer from '../components/Footer';
import AppContextProvider from './AppContextProvider';
import AppRoute, { history } from './AppRoute';
import AppRoute from './AppRoute';
import { AppProps, AppContextProvider } from './AppContext';
const StyledBoxContent = styled(Box)<{ theme?: Theme }>(({ theme }) => ({
padding: 15,
[`@media screen and (min-width: ${theme && theme.breakPoints.container}px)`]: {
maxWidth: theme && theme.breakPoints.container,
width: '100%',
marginLeft: 'auto',
marginRight: 'auto',
},
}));
/* eslint-disable react/jsx-no-bind */
/* eslint-disable react-hooks/exhaustive-deps */
const App: React.FC = () => {
const [user, setUser] = useState();
const [packages, setPackages] = useState([]);
const [isLoading, setIsLoading] = useState(true);
/**
* Logouts user
* Required by: <Header />
*/
const logout = () => {
storage.removeItem('username');
storage.removeItem('token');
setUser(undefined);
export default class App extends Component<{}, AppProps> {
public state: AppProps = {
logoUrl: window.VERDACCIO_LOGO,
user: {},
scope: window.VERDACCIO_SCOPE || '',
showLoginModal: false,
isUserLoggedIn: false,
packages: [],
isLoading: true,
};
const checkUserAlreadyLoggedIn = () => {
public componentDidMount(): void {
this.isUserAlreadyLoggedIn();
this.loadOnHandler();
}
// eslint-disable-next-line no-unused-vars
public componentDidUpdate(_: AppProps, prevState: AppProps): void {
const { isUserLoggedIn } = this.state;
if (prevState.isUserLoggedIn !== isUserLoggedIn) {
this.loadOnHandler();
}
}
public render(): React.ReactElement<HTMLDivElement> {
const { isLoading, isUserLoggedIn, packages, logoUrl, user, scope } = this.state;
const context = { isUserLoggedIn, packages, logoUrl, user, scope };
return (
<Container isLoading={isLoading}>
{isLoading ? <Loading /> : <AppContextProvider value={context}>{this.renderContent()}</AppContextProvider>}
{this.renderLoginModal()}
</Container>
);
}
public isUserAlreadyLoggedIn = () => {
// checks for token validity
const token = storage.getItem('token');
const username = storage.getItem('username');
const username: string = storage.getItem('username') as string;
if (isTokenExpire(token) || isNil(username)) {
logout();
return;
this.handleLogout();
} else {
this.setState({
user: { username },
isUserLoggedIn: true,
});
}
setUser({ username });
};
const loadOnHandler = async () => {
public loadOnHandler = async () => {
try {
const packages = await API.request('packages', 'GET');
// FIXME add correct type for package
setPackages(packages as never[]);
const packages = await API.request<any[]>('packages', 'GET');
// @ts-ignore: FIX THIS TYPE: Type 'any[]' is not assignable to type '[]'
this.setState({
packages,
isLoading: false,
});
} catch (error) {
// FIXME: add dialog
console.error({
title: 'Warning',
message: `Unable to load package list: ${error.message}`,
});
this.setLoading(false);
}
setIsLoading(false);
};
useEffect(() => {
checkUserAlreadyLoggedIn();
loadOnHandler();
}, []);
public setLoading = (isLoading: boolean) =>
this.setState({
isLoading,
});
return (
<>
<StyleBaseline />
<Box display="flex" flexDirection="column" height="100%">
{isLoading ? (
<Loading />
) : (
<>
<Router history={history}>
<AppContextProvider packages={packages} user={user}>
<Header />
<StyledBoxContent flexGrow={1}>
<AppRoute />
</StyledBoxContent>
</AppContextProvider>
</Router>
<Footer />
</>
)}
</Box>
</>
);
};
/**
* Toggles the login modal
* Required by: <LoginModal /> <Header />
*/
public handleToggleLoginModal = () => {
this.setState(prevState => ({
showLoginModal: !prevState.showLoginModal,
}));
};
export default App;
/**
* handles login
* Required by: <Header />
*/
public handleDoLogin = async (usernameValue: string, passwordValue: string) => {
const { username, token, error } = await makeLogin(usernameValue, passwordValue);
if (username && token) {
storage.setItem('username', username);
storage.setItem('token', token);
this.setLoggedUser(username);
}
if (error) {
this.setState({
user: {},
error,
});
}
};
public setLoggedUser = (username: string) => {
this.setState({
user: {
username,
},
isUserLoggedIn: true, // close login modal after successful login
showLoginModal: false, // set isUserLoggedIn to true
});
};
/**
* Logouts user
* Required by: <Header />
*/
public handleLogout = () => {
storage.removeItem('username');
storage.removeItem('token');
this.setState({
user: {},
isUserLoggedIn: false,
});
};
public renderLoginModal = (): ReactElement<HTMLElement> => {
const { error, showLoginModal } = this.state;
return (
<LoginModal
error={error}
onCancel={this.handleToggleLoginModal}
onSubmit={this.handleDoLogin}
visibility={showLoginModal}
/>
);
};
public renderContent = (): ReactElement<HTMLElement> => {
return (
<>
<Content>
<AppRoute>{this.renderHeader()}</AppRoute>
</Content>
<Footer />
</>
);
};
public renderHeader = (): ReactElement<HTMLElement> => {
const {
logoUrl,
user: { username },
scope,
} = this.state;
return (
<Header
logo={logoUrl}
onLogout={this.handleLogout}
onToggleLoginModal={this.handleToggleLoginModal}
scope={scope}
username={username}
/>
);
};
}

View File

@@ -1,19 +0,0 @@
import { createContext } from 'react';
export interface AppProps {
user?: User;
scope: string;
packages: any[];
}
export interface User {
username: string;
}
export interface AppContextProps extends AppProps {
setUser: (user?: User) => void;
}
const AppContext = createContext<undefined | AppContextProps>(undefined);
export default AppContext;

20
src/App/AppContext.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { createContext } from 'react';
import { FormError } from '../components/Login/Login';
export interface AppProps {
error?: FormError;
logoUrl: string;
user: {
username?: string;
};
scope: string;
showLoginModal: boolean;
isUserLoggedIn: boolean;
packages: [];
isLoading: boolean;
}
export const AppContext = createContext<Partial<AppProps>>({});
export const AppContextProvider = AppContext.Provider;
export const AppContextConsumer = AppContext.Consumer;

View File

@@ -1,50 +0,0 @@
import React, { useState, useEffect } from 'react';
import AppContext, { AppProps, User } from './AppContext';
interface Props {
packages: any[];
user?: User;
}
/* eslint-disable react-hooks/exhaustive-deps */
const AppContextProvider: React.FC<Props> = ({ children, packages, user }) => {
const [state, setState] = useState<AppProps>({
scope: window.VERDACCIO_SCOPE || '',
packages,
user,
});
useEffect(() => {
setState({
...state,
user,
});
}, [user]);
useEffect(() => {
setState({
...state,
packages,
});
}, [packages]);
const setUser = (user?: User) => {
setState({
...state,
user,
});
};
return (
<AppContext.Provider
value={{
...state,
setUser,
}}>
{children}
</AppContext.Provider>
);
};
export default AppContextProvider;

View File

@@ -4,7 +4,7 @@ import { createBrowserHistory } from 'history';
import Loading from '../components/Loading';
import AppContext from './AppContext';
import { AppContext } from './AppContext';
const NotFound = lazy(() => import('../components/NotFound'));
const VersionContextProvider = lazy(() => import('../pages/Version/VersionContextProvider'));
@@ -19,24 +19,19 @@ enum Route {
PACKAGE_VERSION = '/-/web/detail/:package/v/:version',
}
export const history = createBrowserHistory({
const history = createBrowserHistory({
basename: window.__VERDACCIO_BASENAME_UI_OPTIONS && window.__VERDACCIO_BASENAME_UI_OPTIONS.url_prefix,
});
const AppRoute: React.FC = () => {
/* eslint react/jsx-max-depth: 0 */
const AppRoute: React.FC = ({ children }) => {
const appContext = useContext(AppContext);
if (!appContext) {
throw Error('The app Context was not correct used');
}
const { user, packages } = appContext;
const isUserLoggedIn = user && user.username;
const { isUserLoggedIn, packages } = appContext;
return (
<Router history={history}>
<Suspense fallback={<Loading />}>
{children}
<Switch>
<ReactRouterDomRoute exact={true} path={Route.ROOT}>
<HomePage isUserLoggedIn={!!isUserLoggedIn} packages={packages || []} />

View File

@@ -1,179 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<App /> should display the Header component 1`] = `
.emotion-8 {
-webkit-transform: translate(-50%,-50%);
-ms-transform: translate(-50%,-50%);
transform: translate(-50%,-50%);
top: 50%;
left: 50%;
position: absolute;
}
.emotion-2 {
margin: 0 0 30px 0;
border-radius: 25px;
box-shadow: 0 10px 20px 0 rgba(69,58,100,0.2);
background: #f7f8f6;
}
.emotion-0 {
display: inline-block;
vertical-align: middle;
box-sizing: border-box;
background-position: center;
background-size: contain;
background-image: url([object Object]);
background-repeat: no-repeat;
width: 90px;
height: 90px;
}
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.emotion-4 {
color: #4b5e40;
}
<div
class="MuiBox-root MuiBox-root-218"
>
<div
class="emotion-8 emotion-9"
data-testid="loading"
>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
/>
</div>
<div
class="emotion-6 emotion-7"
>
<div
class="MuiCircularProgress-root emotion-4 emotion-5 MuiCircularProgress-colorPrimary MuiCircularProgress-indeterminate"
role="progressbar"
style="width: 50px; height: 50px;"
>
<svg
class="MuiCircularProgress-svg"
viewBox="22 22 44 44"
>
<circle
class="MuiCircularProgress-circle MuiCircularProgress-circleIndeterminate"
cx="44"
cy="44"
fill="none"
r="20.2"
stroke-width="3.6"
/>
</svg>
</div>
</div>
</div>
</div>
`;
exports[`<App /> should display the Loading component at the beginning 1`] = `
.emotion-8 {
-webkit-transform: translate(-50%,-50%);
-ms-transform: translate(-50%,-50%);
transform: translate(-50%,-50%);
top: 50%;
left: 50%;
position: absolute;
}
.emotion-2 {
margin: 0 0 30px 0;
border-radius: 25px;
box-shadow: 0 10px 20px 0 rgba(69,58,100,0.2);
background: #f7f8f6;
}
.emotion-0 {
display: inline-block;
vertical-align: middle;
box-sizing: border-box;
background-position: center;
background-size: contain;
background-image: url([object Object]);
background-repeat: no-repeat;
width: 90px;
height: 90px;
}
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.emotion-4 {
color: #4b5e40;
}
<div
class="MuiBox-root MuiBox-root-2"
>
<div
class="emotion-8 emotion-9"
data-testid="loading"
>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
/>
</div>
<div
class="emotion-6 emotion-7"
>
<div
class="MuiCircularProgress-root emotion-4 emotion-5 MuiCircularProgress-colorPrimary MuiCircularProgress-indeterminate"
role="progressbar"
style="width: 50px; height: 50px;"
>
<svg
class="MuiCircularProgress-svg"
viewBox="22 22 44 44"
>
<circle
class="MuiCircularProgress-circle MuiCircularProgress-circleIndeterminate"
cx="44"
cy="44"
fill="none"
r="20.2"
stroke-width="3.6"
/>
</svg>
</div>
</div>
</div>
</div>
`;

View File

@@ -1,2 +1 @@
export { default } from './App';
export { default as AppContextProvider } from './AppContextProvider';

View File

@@ -1,71 +1,89 @@
import React from 'react';
import { render, cleanup } from '../../utils/test-react-testing-library';
import { DetailContext, DetailContextProps } from '../../pages/Version';
import { mount } from '../../utils/test-enzyme';
import api from '../../utils/api';
import ActionBar from './ActionBar';
import { ActionBar } from './ActionBar';
const detailContextValue: DetailContextProps = {
packageName: 'foo',
readMe: 'test',
enableLoading: () => {},
isLoading: false,
hasNotBeenFound: false,
packageMeta: {
_uplinks: {},
latest: {
name: '@verdaccio/local-storage',
version: '8.0.1-next.1',
dist: { fileCount: 0, unpackedSize: 0, tarball: 'http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz' },
homepage: 'https://verdaccio.org',
bugs: {
url: 'https://github.com/verdaccio/monorepo/issues',
},
const mockPackageMeta: jest.Mock = jest.fn(() => ({
latest: {
homepage: 'https://verdaccio.tld',
bugs: {
url: 'https://verdaccio.tld/bugs',
},
dist: {
tarball: 'https://verdaccio.tld/download',
},
},
};
}));
const ComponentToBeRendered: React.FC<{ contextValue: DetailContextProps }> = ({ contextValue }) => (
<DetailContext.Provider value={contextValue}>
<ActionBar />
</DetailContext.Provider>
);
jest.mock('../../pages/Version', () => ({
DetailContextConsumer: component => {
return component.children({ packageMeta: mockPackageMeta() });
},
}));
describe('<ActionBar /> component', () => {
afterEach(() => {
cleanup();
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
});
test('should render the component in default state', () => {
const { container } = render(<ComponentToBeRendered contextValue={detailContextValue} />);
expect(container.firstChild).toMatchSnapshot();
const wrapper = mount(<ActionBar />);
expect(wrapper.html()).toMatchSnapshot();
});
test('when there is no action bar data', () => {
const packageMeta = {
...detailContextValue.packageMeta,
latest: {
...detailContextValue.packageMeta.latest,
homepage: undefined,
bugs: undefined,
dist: {
...detailContextValue.packageMeta.latest.dist,
tarball: undefined,
},
},
};
mockPackageMeta.mockImplementation(() => ({
latest: {},
}));
const { container } = render(<ComponentToBeRendered contextValue={{ ...detailContextValue, packageMeta }} />);
expect(container.firstChild).toMatchSnapshot();
const wrapper = mount(<ActionBar />);
// FIXME: this only renders the DetailContextConsumer, thus
// the wrapper will be always empty
expect(wrapper.html()).toEqual('');
});
test('when there is no latest property in package meta', () => {
mockPackageMeta.mockImplementation(() => ({}));
const wrapper = mount(<ActionBar />);
expect(wrapper.html()).toEqual('');
});
test('when there is a button to download a tarball', () => {
const { getByTitle } = render(<ComponentToBeRendered contextValue={{ ...detailContextValue }} />);
expect(getByTitle('Download tarball')).toBeTruthy();
mockPackageMeta.mockImplementation(() => ({
latest: {
dist: {
tarball: 'http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz',
},
},
}));
const wrapper = mount(<ActionBar />);
expect(wrapper.html()).toMatchSnapshot();
const button = wrapper.find('button');
expect(button).toHaveLength(1);
const spy = jest.spyOn(api, 'request');
button.simulate('click');
expect(spy).toHaveBeenCalled();
});
test('when there is a button to open an issue', () => {
const { getByTitle } = render(<ComponentToBeRendered contextValue={{ ...detailContextValue }} />);
expect(getByTitle('Open an issue')).toBeTruthy();
mockPackageMeta.mockImplementation(() => ({
latest: {
bugs: {
url: 'https://verdaccio.tld/bugs',
},
},
}));
const wrapper = mount(<ActionBar />);
expect(wrapper.html()).toMatchSnapshot();
const button = wrapper.find('button');
expect(button).toHaveLength(1);
});
});

View File

@@ -1,44 +1,133 @@
import React from 'react';
import React, { Component, ReactElement } from 'react';
import BugReportIcon from '@material-ui/icons/BugReport';
import DownloadIcon from '@material-ui/icons/CloudDownload';
import HomeIcon from '@material-ui/icons/Home';
import { DetailContext } from '../../pages/Version';
import { isURL } from '../../utils/url';
import Box from '../../muiComponents/Box';
import { DetailContextConsumer, VersionPageConsumerProps } from '../../pages/Version';
import { isURL, extractFileName, downloadFile } from '../../utils/url';
import api from '../../utils/api';
import Tooltip from '../../muiComponents/Tooltip';
import List from '../../muiComponents/List';
import ActionBarAction, { ActionBarActionProps } from './ActionBarAction';
import { Fab, ActionListItem } from './styles';
/* eslint-disable verdaccio/jsx-spread */
const ActionBar: React.FC = () => {
const detailContext = React.useContext(DetailContext);
export interface Action {
icon: string;
title: string;
handler?: Function;
}
const { packageMeta } = detailContext;
export async function downloadHandler(link: string): Promise<void> {
const fileStream: Blob = await api.request(link, 'GET', {
headers: {
['accept']:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3',
},
credentials: 'include',
});
const fileName = extractFileName(link);
downloadFile(fileStream, fileName);
}
if (!packageMeta?.latest) {
return null;
}
const { homepage, bugs, dist } = packageMeta.latest;
const actions: Array<ActionBarActionProps> = [];
if (homepage && isURL(homepage)) {
actions.push({ type: 'VISIT_HOMEPAGE', link: homepage });
}
if (bugs?.url && isURL(bugs.url)) {
actions.push({ type: 'OPEN_AN_ISSUE', link: bugs.url });
}
if (dist?.tarball && isURL(dist.tarball)) {
actions.push({ type: 'DOWNLOAD_TARBALL', link: dist.tarball });
}
return (
<Box alignItems="center" display="flex" marginBottom="8px">
{actions.map(action => (
<ActionBarAction key={action.link} {...action} />
))}
</Box>
);
const ACTIONS = {
homepage: {
icon: <HomeIcon />,
title: 'Visit homepage',
},
issue: {
icon: <BugReportIcon />,
title: 'Open an issue',
},
tarball: {
icon: <DownloadIcon />,
title: 'Download tarball',
handler: downloadHandler,
},
};
export default ActionBar;
class ActionBar extends Component {
public render(): ReactElement<HTMLElement> {
return (
<DetailContextConsumer>
{context => {
const { packageMeta } = context;
if (!packageMeta) {
return null;
}
return this.renderActionBar(context as VersionPageConsumerProps);
}}
</DetailContextConsumer>
);
}
private renderIconsWithLink(link: string, component: JSX.Element): ReactElement<HTMLElement> {
return (
<a href={link} target={'_blank'}>
{component}
</a>
);
}
private renderActionBar = ({ packageMeta }) => {
const { latest } = packageMeta;
if (!latest) {
return null;
}
const { homepage, bugs, dist } = latest;
const actionsMap = {
homepage,
issue: bugs ? bugs.url : null,
tarball: dist ? dist.tarball : null,
};
const renderList = Object.keys(actionsMap).reduce((component: React.ReactElement[], value, key) => {
const link = actionsMap[value];
if (link && isURL(link)) {
const actionItem: Action = ACTIONS[value];
if (actionItem.handler) {
const fab = (
<Tooltip key={key} title={actionItem['title']}>
<Fab
/* eslint-disable react/jsx-no-bind */
onClick={() => {
/* eslint-disable @typescript-eslint/no-non-null-assertion */
actionItem.handler!(link);
}}
size={'small'}>
{actionItem['icon']}
</Fab>
</Tooltip>
);
component.push(fab);
} else {
const fab = <Fab size={'small'}>{actionItem['icon']}</Fab>;
component.push(
<Tooltip key={key} title={actionItem['title']}>
<>{this.renderIconsWithLink(link, fab)}</>
</Tooltip>
);
}
}
return component;
}, []);
if (renderList.length > 0) {
return (
<List>
<ActionListItem alignItems={'flex-start'} button={true}>
{renderList}
</ActionListItem>
</List>
);
}
return null;
};
}
export { ActionBar };

View File

@@ -1,61 +0,0 @@
import React from 'react';
import styled from '@emotion/styled';
import BugReportIcon from '@material-ui/icons/BugReport';
import DownloadIcon from '@material-ui/icons/CloudDownload';
import HomeIcon from '@material-ui/icons/Home';
import Tooltip from '../../muiComponents/Tooltip';
import Link from '../Link';
import FloatingActionButton from '../../muiComponents/FloatingActionButton';
import { Theme } from '../../design-tokens/theme';
import downloadTarball from './download-tarball';
export const Fab = styled(FloatingActionButton)<{ theme?: Theme }>(props => ({
backgroundColor: props.theme && props.theme.palette.primary.main,
color: props.theme && props.theme.palette.white,
marginRight: 10,
}));
type ActionType = 'VISIT_HOMEPAGE' | 'OPEN_AN_ISSUE' | 'DOWNLOAD_TARBALL';
export interface ActionBarActionProps {
type: ActionType;
link: string;
}
/* eslint-disable react/jsx-no-bind */
const ActionBarAction: React.FC<ActionBarActionProps> = ({ type, link }) => {
switch (type) {
case 'VISIT_HOMEPAGE':
return (
<Tooltip title="Visit homepage">
<Link external={true} to={link}>
<Fab size="small">
<HomeIcon />
</Fab>
</Link>
</Tooltip>
);
case 'OPEN_AN_ISSUE':
return (
<Tooltip title="Open an issue">
<Link external={true} to={link}>
<Fab size="small">
<BugReportIcon />
</Fab>
</Link>
</Tooltip>
);
case 'DOWNLOAD_TARBALL':
return (
<Tooltip title="Download tarball">
<Fab data-testid="download-tarball-btn" onClick={downloadTarball(link)} size="small">
<DownloadIcon />
</Fab>
</Tooltip>
);
}
};
export default ActionBarAction;

View File

@@ -1,118 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ActionBar /> component should render the component in default state 1`] = `
.emotion-0 {
background-color: #4b5e40;
color: #fff;
margin-right: 10px;
}
exports[`<ActionBar /> component should render the component in default state 1`] = `""`;
<div
class="MuiBox-root MuiBox-root-2"
>
<a
class=""
href="https://verdaccio.org"
rel="noopener noreferrer"
target="_blank"
title="Visit homepage"
>
<h6
class="MuiTypography-root MuiTypography-subtitle1"
>
<button
class="MuiButtonBase-root MuiFab-root emotion-0 emotion-1 MuiFab-sizeSmall"
tabindex="0"
type="button"
>
<span
class="MuiFab-label"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"
/>
</svg>
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</h6>
</a>
<a
class=""
href="https://github.com/verdaccio/monorepo/issues"
rel="noopener noreferrer"
target="_blank"
title="Open an issue"
>
<h6
class="MuiTypography-root MuiTypography-subtitle1"
>
<button
class="MuiButtonBase-root MuiFab-root emotion-0 emotion-1 MuiFab-sizeSmall"
tabindex="0"
type="button"
>
<span
class="MuiFab-label"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"
/>
</svg>
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</h6>
</a>
<button
class="MuiButtonBase-root MuiFab-root emotion-0 emotion-1 MuiFab-sizeSmall"
data-testid="download-tarball-btn"
tabindex="0"
title="Download tarball"
type="button"
>
<span
class="MuiFab-label"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"
/>
</svg>
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</div>
`;
exports[`<ActionBar /> component when there is a button to download a tarball 1`] = `"<ul class=\\"MuiList-root MuiList-padding\\"><div class=\\"MuiButtonBase-root MuiListItem-root css-l3mdff-ActionListItem eux6shq0 MuiListItem-gutters MuiListItem-button MuiListItem-alignItemsFlexStart\\" tabindex=\\"0\\" role=\\"button\\" aria-disabled=\\"false\\"><button class=\\"MuiButtonBase-root MuiFab-root css-is03ew-Fab eux6shq1 MuiFab-sizeSmall\\" tabindex=\\"0\\" type=\\"button\\" title=\\"Download tarball\\"><span class=\\"MuiFab-label\\"><svg class=\\"MuiSvgIcon-root\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path d=\\"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z\\"></path></svg></span><span class=\\"MuiTouchRipple-root\\"></span></button><span class=\\"MuiTouchRipple-root\\"></span></div></ul>"`;
exports[`<ActionBar /> component when there is no action bar data 1`] = `
<div
class="MuiBox-root MuiBox-root-77"
/>
`;
exports[`<ActionBar /> component when there is a button to open an issue 1`] = `"<ul class=\\"MuiList-root MuiList-padding\\"><div class=\\"MuiButtonBase-root MuiListItem-root css-l3mdff-ActionListItem eux6shq0 MuiListItem-gutters MuiListItem-button MuiListItem-alignItemsFlexStart\\" tabindex=\\"0\\" role=\\"button\\" aria-disabled=\\"false\\"><a href=\\"https://verdaccio.tld/bugs\\" target=\\"_blank\\"><button class=\\"MuiButtonBase-root MuiFab-root css-is03ew-Fab eux6shq1 MuiFab-sizeSmall\\" tabindex=\\"0\\" type=\\"button\\"><span class=\\"MuiFab-label\\"><svg class=\\"MuiSvgIcon-root\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path d=\\"M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z\\"></path></svg></span><span class=\\"MuiTouchRipple-root\\"></span></button></a><span class=\\"MuiTouchRipple-root\\"></span></div></ul>"`;

View File

@@ -1,18 +0,0 @@
import api from '../../utils/api';
import { extractFileName, downloadFile } from '../../utils/url';
function downloadTarball(link: string) {
return async function downloadHandler(): Promise<void> {
const fileStream: Blob = await api.request(link, 'GET', {
headers: {
['accept']:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3',
},
credentials: 'include',
});
const fileName = extractFileName(link);
downloadFile(fileStream, fileName);
};
}
export default downloadTarball;

View File

@@ -1,2 +1 @@
export { default } from './ActionBar';
export { default as downloadTarball } from './download-tarball';

View File

@@ -0,0 +1,17 @@
import styled from '@emotion/styled';
import ListItem from '../../muiComponents/ListItem';
import FloatingActionButton from '../../muiComponents/FloatingActionButton';
import { Theme } from '../../design-tokens/theme';
export const ActionListItem = styled(ListItem)({
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
});
export const Fab = styled(FloatingActionButton)<{ theme?: Theme }>(props => ({
backgroundColor: props.theme && props.theme.palette.primary.main,
color: props.theme && props.theme.palette.white,
marginRight: '10px',
}));

View File

@@ -1,14 +1,14 @@
import styled from '@emotion/styled';
import { fontWeight } from '../../utils/styles/sizes';
import ListItem from '../../muiComponents/ListItem';
import Text from '../../muiComponents/Text';
import ListItemText from '../../muiComponents/ListItemText';
import { Theme } from '../../design-tokens/theme';
export const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
textTransform: 'capitalize',
}));
});
export const AuthorListItem = styled(ListItem)({
padding: 0,

View File

@@ -1,16 +1,16 @@
import React, { KeyboardEvent, memo } from 'react';
import React, { KeyboardEvent } from 'react';
import styled from '@emotion/styled';
import Autosuggest, { SuggestionSelectedEventData, InputProps, ChangeEvent } from 'react-autosuggest';
import match from 'autosuggest-highlight/match';
import parse from 'autosuggest-highlight/parse';
import { fontWeight } from '../../utils/styles/sizes';
import MenuItem from '../../muiComponents/MenuItem';
import { Theme } from '../../design-tokens/theme';
import { Wrapper, InputField, SuggestionContainer } from './styles';
const StyledAnchor = styled('a')<{ highlight: boolean; theme?: Theme }>(props => ({
fontWeight: props.theme && props.highlight ? props.theme.fontWeight.semiBold : props.theme.fontWeight.light,
const StyledAnchor = styled('a')<{ fw: number }>(props => ({
fontWeight: props.fw,
}));
const StyledMenuItem = styled(MenuItem)({
@@ -64,8 +64,9 @@ const renderSuggestion = (suggestion, { query, isHighlighted }): JSX.Element =>
<StyledMenuItem component="div" selected={isHighlighted}>
<div>
{parts.map((part, index) => {
const fw = part.highlight ? fontWeight.semiBold : fontWeight.light;
return (
<StyledAnchor highlight={part.highlight} key={String(index)}>
<StyledAnchor fw={fw} key={String(index)}>
{part.text}
</StyledAnchor>
);
@@ -89,66 +90,64 @@ const SUGGESTIONS_RESPONSE = {
NO_RESULT: 'No results found.',
};
const AutoComplete = memo(
({
const AutoComplete = ({
suggestions,
startAdornment,
onChange,
onSuggestionsFetch,
onCleanSuggestions,
value = '',
placeholder = '',
disableUnderline = false,
onClick,
onKeyDown,
onBlur,
suggestionsLoading = false,
suggestionsLoaded = false,
suggestionsError = false,
}: Props): JSX.Element => {
const autosuggestProps = {
renderInputComponent,
suggestions,
startAdornment,
getSuggestionValue,
renderSuggestion,
onSuggestionsFetchRequested: onSuggestionsFetch,
onSuggestionsClearRequested: onCleanSuggestions,
};
const inputProps: InputProps<unknown> = {
value,
onChange,
onSuggestionsFetch,
onCleanSuggestions,
value = '',
placeholder = '',
disableUnderline = false,
onClick,
placeholder,
// material-ui@4.5.1 introduce better types for TextInput, check readme
// @ts-ignore
startAdornment,
disableUnderline,
onKeyDown,
onBlur,
suggestionsLoading = false,
suggestionsLoaded = false,
suggestionsError = false,
}: Props) => {
const autosuggestProps = {
renderInputComponent,
suggestions,
getSuggestionValue,
renderSuggestion,
onSuggestionsFetchRequested: onSuggestionsFetch,
onSuggestionsClearRequested: onCleanSuggestions,
};
const inputProps: InputProps<unknown> = {
value,
onChange,
placeholder,
// material-ui@4.5.1 introduce better types for TextInput, check readme
// @ts-ignore
startAdornment,
disableUnderline,
onKeyDown,
onBlur,
};
// this format avoid arrow function eslint rule
function renderSuggestionsContainer({ containerProps, children, query }): JSX.Element {
return (
<SuggestionContainer {...containerProps} square={true}>
{suggestionsLoaded && children === null && query && renderMessage(SUGGESTIONS_RESPONSE.NO_RESULT)}
{suggestionsLoading && query && renderMessage(SUGGESTIONS_RESPONSE.LOADING)}
{suggestionsError && renderMessage(SUGGESTIONS_RESPONSE.FAILURE)}
{children}
</SuggestionContainer>
);
}
};
// this format avoid arrow function eslint rule
function renderSuggestionsContainer({ containerProps, children, query }): JSX.Element {
return (
<Wrapper>
<Autosuggest
{...autosuggestProps}
inputProps={inputProps}
onSuggestionSelected={onClick}
renderSuggestionsContainer={renderSuggestionsContainer}
/>
</Wrapper>
<SuggestionContainer {...containerProps} square={true}>
{suggestionsLoaded && children === null && query && renderMessage(SUGGESTIONS_RESPONSE.NO_RESULT)}
{suggestionsLoading && query && renderMessage(SUGGESTIONS_RESPONSE.LOADING)}
{suggestionsError && renderMessage(SUGGESTIONS_RESPONSE.FAILURE)}
{children}
</SuggestionContainer>
);
}
);
return (
<Wrapper>
<Autosuggest
{...autosuggestProps}
inputProps={inputProps}
onSuggestionSelected={onClick}
renderSuggestionsContainer={renderSuggestionsContainer}
/>
</Wrapper>
);
};
export default AutoComplete;

View File

@@ -1,18 +1,18 @@
import styled from '@emotion/styled';
import { fontWeight } from '../../utils/styles/sizes';
import Text from '../../muiComponents/Text';
import Card from '../../muiComponents/Card';
import Chip from '../../muiComponents/Chip';
import { Theme } from '../../design-tokens/theme';
export const CardWrap = styled(Card)({
margin: '0 0 16px',
});
export const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
textTransform: 'capitalize',
}));
});
export const Tags = styled('div')({
display: 'flex',

View File

@@ -1,6 +1,6 @@
import React, { ReactElement } from 'react';
import ActionBar from '../ActionBar';
import { ActionBar } from '../ActionBar/ActionBar';
import Author from '../Author';
import Developers from '../Developers';
import Dist from '../Dist/Dist';

View File

@@ -122,12 +122,9 @@ exports[`test Developers should render the component for contributors with items
<ForwardRef(Tooltip)
classes={
Object {
"arrow": "MuiTooltip-arrow",
"popper": "MuiTooltip-popper",
"popperArrow": "MuiTooltip-popperArrow",
"popperInteractive": "MuiTooltip-popperInteractive",
"tooltip": "MuiTooltip-tooltip",
"tooltipArrow": "MuiTooltip-tooltipArrow",
"tooltipPlacementBottom": "MuiTooltip-tooltipPlacementBottom",
"tooltipPlacementLeft": "MuiTooltip-tooltipPlacementLeft",
"tooltipPlacementRight": "MuiTooltip-tooltipPlacementRight",
@@ -162,7 +159,6 @@ exports[`test Developers should render the component for contributors with items
Object {
"circle": "MuiAvatar-circle",
"colorDefault": "MuiAvatar-colorDefault",
"fallback": "MuiAvatar-fallback",
"img": "MuiAvatar-img",
"root": "MuiAvatar-root",
"rounded": "MuiAvatar-rounded",
@@ -173,44 +169,7 @@ exports[`test Developers should render the component for contributors with items
<div
aria-label="dmethvin"
className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<ForwardRef
className="MuiAvatar-fallback"
>
<WithStyles(ForwardRef(SvgIcon))
className="MuiAvatar-fallback"
>
<ForwardRef(SvgIcon)
className="MuiAvatar-fallback"
classes={
Object {
"colorAction": "MuiSvgIcon-colorAction",
"colorDisabled": "MuiSvgIcon-colorDisabled",
"colorError": "MuiSvgIcon-colorError",
"colorPrimary": "MuiSvgIcon-colorPrimary",
"colorSecondary": "MuiSvgIcon-colorSecondary",
"fontSizeInherit": "MuiSvgIcon-fontSizeInherit",
"fontSizeLarge": "MuiSvgIcon-fontSizeLarge",
"fontSizeSmall": "MuiSvgIcon-fontSizeSmall",
"root": "MuiSvgIcon-root",
}
}
>
<svg
aria-hidden="true"
className="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
</ForwardRef(SvgIcon)>
</WithStyles(ForwardRef(SvgIcon))>
</ForwardRef>
</div>
/>
</ForwardRef(Avatar)>
</WithStyles(ForwardRef(Avatar))>
</ForwardRef(Avatar)>
@@ -226,35 +185,13 @@ exports[`test Developers should render the component for contributors with items
<div
aria-label="dmethvin"
class="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
}
className="MuiTooltip-popper"
id={null}
open={false}
placement="bottom"
popperOptions={
Object {
"modifiers": Object {
"arrow": Object {
"element": null,
"enabled": false,
},
},
}
}
transition={true}
/>
</ForwardRef(Tooltip)>
@@ -284,12 +221,9 @@ exports[`test Developers should render the component for contributors with items
<ForwardRef(Tooltip)
classes={
Object {
"arrow": "MuiTooltip-arrow",
"popper": "MuiTooltip-popper",
"popperArrow": "MuiTooltip-popperArrow",
"popperInteractive": "MuiTooltip-popperInteractive",
"tooltip": "MuiTooltip-tooltip",
"tooltipArrow": "MuiTooltip-tooltipArrow",
"tooltipPlacementBottom": "MuiTooltip-tooltipPlacementBottom",
"tooltipPlacementLeft": "MuiTooltip-tooltipPlacementLeft",
"tooltipPlacementRight": "MuiTooltip-tooltipPlacementRight",
@@ -324,7 +258,6 @@ exports[`test Developers should render the component for contributors with items
Object {
"circle": "MuiAvatar-circle",
"colorDefault": "MuiAvatar-colorDefault",
"fallback": "MuiAvatar-fallback",
"img": "MuiAvatar-img",
"root": "MuiAvatar-root",
"rounded": "MuiAvatar-rounded",
@@ -335,44 +268,7 @@ exports[`test Developers should render the component for contributors with items
<div
aria-label="mgol"
className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<ForwardRef
className="MuiAvatar-fallback"
>
<WithStyles(ForwardRef(SvgIcon))
className="MuiAvatar-fallback"
>
<ForwardRef(SvgIcon)
className="MuiAvatar-fallback"
classes={
Object {
"colorAction": "MuiSvgIcon-colorAction",
"colorDisabled": "MuiSvgIcon-colorDisabled",
"colorError": "MuiSvgIcon-colorError",
"colorPrimary": "MuiSvgIcon-colorPrimary",
"colorSecondary": "MuiSvgIcon-colorSecondary",
"fontSizeInherit": "MuiSvgIcon-fontSizeInherit",
"fontSizeLarge": "MuiSvgIcon-fontSizeLarge",
"fontSizeSmall": "MuiSvgIcon-fontSizeSmall",
"root": "MuiSvgIcon-root",
}
}
>
<svg
aria-hidden="true"
className="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
</ForwardRef(SvgIcon)>
</WithStyles(ForwardRef(SvgIcon))>
</ForwardRef>
</div>
/>
</ForwardRef(Avatar)>
</WithStyles(ForwardRef(Avatar))>
</ForwardRef(Avatar)>
@@ -388,35 +284,13 @@ exports[`test Developers should render the component for contributors with items
<div
aria-label="mgol"
class="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
}
className="MuiTooltip-popper"
id={null}
open={false}
placement="bottom"
popperOptions={
Object {
"modifiers": Object {
"arrow": Object {
"element": null,
"enabled": false,
},
},
}
}
transition={true}
/>
</ForwardRef(Tooltip)>
@@ -552,12 +426,9 @@ exports[`test Developers should render the component for maintainers with items
<ForwardRef(Tooltip)
classes={
Object {
"arrow": "MuiTooltip-arrow",
"popper": "MuiTooltip-popper",
"popperArrow": "MuiTooltip-popperArrow",
"popperInteractive": "MuiTooltip-popperInteractive",
"tooltip": "MuiTooltip-tooltip",
"tooltipArrow": "MuiTooltip-tooltipArrow",
"tooltipPlacementBottom": "MuiTooltip-tooltipPlacementBottom",
"tooltipPlacementLeft": "MuiTooltip-tooltipPlacementLeft",
"tooltipPlacementRight": "MuiTooltip-tooltipPlacementRight",
@@ -592,7 +463,6 @@ exports[`test Developers should render the component for maintainers with items
Object {
"circle": "MuiAvatar-circle",
"colorDefault": "MuiAvatar-colorDefault",
"fallback": "MuiAvatar-fallback",
"img": "MuiAvatar-img",
"root": "MuiAvatar-root",
"rounded": "MuiAvatar-rounded",
@@ -603,44 +473,7 @@ exports[`test Developers should render the component for maintainers with items
<div
aria-label="dmethvin"
className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<ForwardRef
className="MuiAvatar-fallback"
>
<WithStyles(ForwardRef(SvgIcon))
className="MuiAvatar-fallback"
>
<ForwardRef(SvgIcon)
className="MuiAvatar-fallback"
classes={
Object {
"colorAction": "MuiSvgIcon-colorAction",
"colorDisabled": "MuiSvgIcon-colorDisabled",
"colorError": "MuiSvgIcon-colorError",
"colorPrimary": "MuiSvgIcon-colorPrimary",
"colorSecondary": "MuiSvgIcon-colorSecondary",
"fontSizeInherit": "MuiSvgIcon-fontSizeInherit",
"fontSizeLarge": "MuiSvgIcon-fontSizeLarge",
"fontSizeSmall": "MuiSvgIcon-fontSizeSmall",
"root": "MuiSvgIcon-root",
}
}
>
<svg
aria-hidden="true"
className="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
</ForwardRef(SvgIcon)>
</WithStyles(ForwardRef(SvgIcon))>
</ForwardRef>
</div>
/>
</ForwardRef(Avatar)>
</WithStyles(ForwardRef(Avatar))>
</ForwardRef(Avatar)>
@@ -656,35 +489,13 @@ exports[`test Developers should render the component for maintainers with items
<div
aria-label="dmethvin"
class="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
}
className="MuiTooltip-popper"
id={null}
open={false}
placement="bottom"
popperOptions={
Object {
"modifiers": Object {
"arrow": Object {
"element": null,
"enabled": false,
},
},
}
}
transition={true}
/>
</ForwardRef(Tooltip)>
@@ -714,12 +525,9 @@ exports[`test Developers should render the component for maintainers with items
<ForwardRef(Tooltip)
classes={
Object {
"arrow": "MuiTooltip-arrow",
"popper": "MuiTooltip-popper",
"popperArrow": "MuiTooltip-popperArrow",
"popperInteractive": "MuiTooltip-popperInteractive",
"tooltip": "MuiTooltip-tooltip",
"tooltipArrow": "MuiTooltip-tooltipArrow",
"tooltipPlacementBottom": "MuiTooltip-tooltipPlacementBottom",
"tooltipPlacementLeft": "MuiTooltip-tooltipPlacementLeft",
"tooltipPlacementRight": "MuiTooltip-tooltipPlacementRight",
@@ -754,7 +562,6 @@ exports[`test Developers should render the component for maintainers with items
Object {
"circle": "MuiAvatar-circle",
"colorDefault": "MuiAvatar-colorDefault",
"fallback": "MuiAvatar-fallback",
"img": "MuiAvatar-img",
"root": "MuiAvatar-root",
"rounded": "MuiAvatar-rounded",
@@ -765,44 +572,7 @@ exports[`test Developers should render the component for maintainers with items
<div
aria-label="mgol"
className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<ForwardRef
className="MuiAvatar-fallback"
>
<WithStyles(ForwardRef(SvgIcon))
className="MuiAvatar-fallback"
>
<ForwardRef(SvgIcon)
className="MuiAvatar-fallback"
classes={
Object {
"colorAction": "MuiSvgIcon-colorAction",
"colorDisabled": "MuiSvgIcon-colorDisabled",
"colorError": "MuiSvgIcon-colorError",
"colorPrimary": "MuiSvgIcon-colorPrimary",
"colorSecondary": "MuiSvgIcon-colorSecondary",
"fontSizeInherit": "MuiSvgIcon-fontSizeInherit",
"fontSizeLarge": "MuiSvgIcon-fontSizeLarge",
"fontSizeSmall": "MuiSvgIcon-fontSizeSmall",
"root": "MuiSvgIcon-root",
}
}
>
<svg
aria-hidden="true"
className="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
</ForwardRef(SvgIcon)>
</WithStyles(ForwardRef(SvgIcon))>
</ForwardRef>
</div>
/>
</ForwardRef(Avatar)>
</WithStyles(ForwardRef(Avatar))>
</ForwardRef(Avatar)>
@@ -818,35 +588,13 @@ exports[`test Developers should render the component for maintainers with items
<div
aria-label="mgol"
class="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiAvatar-fallback"
focusable="false"
role="presentation"
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>
}
className="MuiTooltip-popper"
id={null}
open={false}
placement="bottom"
popperOptions={
Object {
"modifiers": Object {
"arrow": Object {
"element": null,
"enabled": false,
},
},
}
}
transition={true}
/>
</ForwardRef(Tooltip)>

View File

@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { fontWeight } from '../../utils/styles/sizes';
import Text from '../../muiComponents/Text';
import FloatingActionButton from '../../muiComponents/FloatingActionButton';
import { Theme } from '../../design-tokens/theme';
@@ -19,11 +20,11 @@ export const Content = styled('div')({
},
});
export const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
marginBottom: '10px',
textTransform: 'capitalize',
}));
});
export const Fab = styled(FloatingActionButton)<{ theme?: Theme }>(props => ({
backgroundColor: props.theme && props.theme.palette.primary.main,

View File

@@ -10,6 +10,8 @@ import { StyledText, DistListItem, DistChips } from './styles';
const DistChip: FC<{ name: string }> = ({ name, children }) =>
children ? (
<DistChips
// lint rule conflicting with prettier
/* eslint-disable react/jsx-wrap-multilines */
label={
<>
<b>{name}</b>
@@ -17,6 +19,7 @@ const DistChip: FC<{ name: string }> = ({ name, children }) =>
{children}
</>
}
/* eslint-enable */
/>
) : null;

View File

@@ -1,15 +1,16 @@
import styled from '@emotion/styled';
import { Theme } from '../../design-tokens/theme';
import { fontWeight } from '../../utils/styles/sizes';
import ListItem from '../../muiComponents/ListItem';
import Text from '../../muiComponents/Text';
import FloatingActionButton from '../../muiComponents/FloatingActionButton';
import Chip from '../../muiComponents/Chip';
import { Theme } from '../../design-tokens/theme';
export const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
textTransform: 'capitalize',
}));
});
export const DistListItem = styled(ListItem)({
paddingLeft: 0,

View File

@@ -13,12 +13,13 @@ import node from './img/node.png';
const Engine: React.FC = () => {
const { packageMeta } = useContext(DetailContext);
const engines = packageMeta?.latest?.engines;
const engines = packageMeta && packageMeta.latest && packageMeta.latest.engines;
if (!engines || (!engines.node && !engines.npm)) {
return null;
}
/* eslint-disable react/jsx-max-depth */
return (
<Grid container={true}>
{engines.node && (
@@ -44,6 +45,7 @@ const Engine: React.FC = () => {
)}
</Grid>
);
/* eslint-enable react/jsx-max-depth */
};
export default Engine;

View File

@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Engines /> component should render the component in default state 1`] = `"<div class=\\"MuiGrid-root MuiGrid-container\\"><div class=\\"MuiGrid-root MuiGrid-item MuiGrid-grid-xs-6\\"><ul class=\\"MuiList-root MuiList-padding MuiList-subheader\\"><h6 class=\\"MuiTypography-root css-1na337r-StyledText et66bt70 MuiTypography-subtitle1\\">node JS</h6><div class=\\"MuiButtonBase-root MuiListItem-root css-18b06t0-EngineListItem et66bt71 MuiListItem-gutters MuiListItem-button\\" tabindex=\\"0\\" role=\\"button\\" aria-disabled=\\"false\\"><div class=\\"MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault\\"><svg class=\\"MuiSvgIcon-root MuiAvatar-fallback\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><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\\"></path></svg></div><div class=\\"MuiListItemText-root\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">&gt;= 0.1.98</span></div><span class=\\"MuiTouchRipple-root\\"></span></div></ul></div><div class=\\"MuiGrid-root MuiGrid-item MuiGrid-grid-xs-6\\"><ul class=\\"MuiList-root MuiList-padding MuiList-subheader\\"><h6 class=\\"MuiTypography-root css-1na337r-StyledText et66bt70 MuiTypography-subtitle1\\">NPM version</h6><div class=\\"MuiButtonBase-root MuiListItem-root css-18b06t0-EngineListItem et66bt71 MuiListItem-gutters MuiListItem-button\\" tabindex=\\"0\\" role=\\"button\\" aria-disabled=\\"false\\"><div class=\\"MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault\\"><svg class=\\"MuiSvgIcon-root MuiAvatar-fallback\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><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\\"></path></svg></div><div class=\\"MuiListItemText-root\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">&gt;3</span></div><span class=\\"MuiTouchRipple-root\\"></span></div></ul></div></div>"`;
exports[`<Engines /> component should render the component in default state 1`] = `"<div class=\\"MuiGrid-root MuiGrid-container\\"><div class=\\"MuiGrid-root MuiGrid-item MuiGrid-grid-xs-6\\"><ul class=\\"MuiList-root MuiList-padding MuiList-subheader\\"><h6 class=\\"MuiTypography-root css-1na337r-StyledText et66bt70 MuiTypography-subtitle1\\">node JS</h6><div class=\\"MuiButtonBase-root MuiListItem-root css-18b06t0-EngineListItem et66bt71 MuiListItem-gutters MuiListItem-button\\" tabindex=\\"0\\" role=\\"button\\" aria-disabled=\\"false\\"><div class=\\"MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault\\"></div><div class=\\"MuiListItemText-root\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">&gt;= 0.1.98</span></div><span class=\\"MuiTouchRipple-root\\"></span></div></ul></div><div class=\\"MuiGrid-root MuiGrid-item MuiGrid-grid-xs-6\\"><ul class=\\"MuiList-root MuiList-padding MuiList-subheader\\"><h6 class=\\"MuiTypography-root css-1na337r-StyledText et66bt70 MuiTypography-subtitle1\\">NPM version</h6><div class=\\"MuiButtonBase-root MuiListItem-root css-18b06t0-EngineListItem et66bt71 MuiListItem-gutters MuiListItem-button\\" tabindex=\\"0\\" role=\\"button\\" aria-disabled=\\"false\\"><div class=\\"MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault\\"></div><div class=\\"MuiListItemText-root\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">&gt;3</span></div><span class=\\"MuiTouchRipple-root\\"></span></div></ul></div></div>"`;

View File

@@ -1,13 +1,13 @@
import styled from '@emotion/styled';
import { fontWeight } from '../../utils/styles/sizes';
import ListItem from '../../muiComponents/ListItem';
import Text from '../../muiComponents/Text';
import { Theme } from '../../design-tokens/theme';
export const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
textTransform: 'capitalize',
}));
});
export const EngineListItem = styled(ListItem)({
paddingLeft: 0,

View File

@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { breakpoints } from '../../utils/styles/media';
import Icon from '../Icon/Icon';
import { Theme } from '../../design-tokens/theme';
@@ -11,29 +12,29 @@ export const Wrapper = styled('div')<{ theme?: Theme }>(props => ({
padding: '20px',
}));
export const Inner = styled('div')<{ theme?: Theme }>(({ theme }) => ({
export const Inner = styled('div')({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
width: '100%',
[`@media (min-width: ${theme && theme.breakPoints.medium}px)`]: {
[`@media (min-width: ${breakpoints.medium}px)`]: {
minWidth: 400,
maxWidth: 800,
margin: 'auto',
justifyContent: 'space-between',
},
[`@media (min-width: ${theme && theme.breakPoints.large}px)`]: {
[`@media (min-width: ${breakpoints.large}px)`]: {
maxWidth: 1240,
},
}));
});
export const Left = styled('div')<{ theme?: Theme }>(({ theme }) => ({
export const Left = styled('div')({
alignItems: 'center',
display: 'none',
[`@media (min-width: ${theme && theme.breakPoints.medium}px)`]: {
[`@media (min-width: ${breakpoints.medium}px)`]: {
display: 'flex',
},
}));
});
export const Right = styled(Left)({
display: 'flex',

View File

@@ -1,16 +1,16 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { render, fireEvent, waitForElement, waitForElementToBeRemoved } from '../../utils/test-react-testing-library';
import { AppContextProvider } from '../../App';
import { render, fireEvent, waitForElementToBeRemoved, waitForElement } from '../../utils/test-react-testing-library';
import Header from './Header';
const props = {
user: {
username: 'verddacio-user',
},
packages: [],
const headerProps = {
username: 'verddacio-user',
scope: 'test scope',
withoutSearch: true,
handleToggleLoginModal: jest.fn(),
handleLogout: jest.fn(),
};
/* eslint-disable react/jsx-no-bind*/
@@ -18,70 +18,82 @@ describe('<Header /> component with logged in state', () => {
test('should load the component in logged out state', () => {
const { container, queryByTestId, getByText } = render(
<Router>
<AppContextProvider packages={props.packages}>
<Header />
</AppContextProvider>
<Header
onLogout={headerProps.handleLogout}
onToggleLoginModal={headerProps.handleToggleLoginModal}
scope={headerProps.scope}
/>
</Router>
);
expect(container.firstChild).toMatchSnapshot();
expect(queryByTestId('header--menu-accountcircle')).toBeNull();
expect(queryByTestId('header--menu-acountcircle')).toBeNull();
expect(getByText('Login')).toBeTruthy();
});
test('should load the component in logged in state', () => {
const { container, getByTestId, queryByText } = render(
<Router>
<AppContextProvider packages={props.packages} user={props.user}>
<Header />
</AppContextProvider>
<Header
onLogout={headerProps.handleLogout}
onToggleLoginModal={headerProps.handleToggleLoginModal}
scope={headerProps.scope}
username={headerProps.username}
/>
</Router>
);
expect(container.firstChild).toMatchSnapshot();
expect(getByTestId('header--menu-accountcircle')).toBeTruthy();
expect(getByTestId('header--menu-acountcircle')).toBeTruthy();
expect(queryByText('Login')).toBeNull();
});
test('should open login dialog', async () => {
const { getByText } = render(
<Router>
<AppContextProvider packages={props.packages}>
<Header />
</AppContextProvider>
<Header
onLogout={headerProps.handleLogout}
onToggleLoginModal={headerProps.handleToggleLoginModal}
scope={headerProps.scope}
/>
</Router>
);
const loginBtn = getByText('Login');
fireEvent.click(loginBtn);
const loginDialog = await waitForElement(() => getByText('Sign in'));
expect(loginDialog).toBeTruthy();
expect(headerProps.handleToggleLoginModal).toHaveBeenCalled();
});
test('should logout the user', async () => {
const { getByText, getByTestId } = render(
<Router>
<AppContextProvider packages={props.packages} user={props.user}>
<Header />
</AppContextProvider>
<Header
onLogout={headerProps.handleLogout}
onToggleLoginModal={headerProps.handleToggleLoginModal}
scope={headerProps.scope}
username={headerProps.username}
/>
</Router>
);
const headerMenuAccountCircle = getByTestId('header--menu-accountcircle');
const headerMenuAccountCircle = getByTestId('header--menu-acountcircle');
fireEvent.click(headerMenuAccountCircle);
// wait for button Logout's appearance and return the element
const logoutBtn = await waitForElement(() => getByText('Logout'));
fireEvent.click(logoutBtn);
expect(getByText('Login')).toBeTruthy();
expect(headerProps.handleLogout).toHaveBeenCalled();
});
test("The question icon should open a new tab of verdaccio's website - installation doc", () => {
test("The question icon should open a new tab of verdaccio's website - installation doc", async () => {
const { getByTestId } = render(
<Router>
<AppContextProvider packages={props.packages} user={props.user}>
<Header />
</AppContextProvider>
<Header
onLogout={headerProps.handleLogout}
onToggleLoginModal={headerProps.handleToggleLoginModal}
scope={headerProps.scope}
username={headerProps.username}
/>
</Router>
);
@@ -92,9 +104,12 @@ describe('<Header /> component with logged in state', () => {
test('should open the registrationInfo modal when clicking on the info icon', async () => {
const { getByTestId } = render(
<Router>
<AppContextProvider packages={props.packages} user={props.user}>
<Header />
</AppContextProvider>
<Header
onLogout={headerProps.handleLogout}
onToggleLoginModal={headerProps.handleToggleLoginModal}
scope={headerProps.scope}
username={headerProps.username}
/>
</Router>
);
@@ -109,9 +124,12 @@ describe('<Header /> component with logged in state', () => {
test('should close the registrationInfo modal when clicking on the button close', async () => {
const { getByTestId, getByText, queryByTestId } = render(
<Router>
<AppContextProvider packages={props.packages} user={props.user}>
<Header />
</AppContextProvider>
<Header
onLogout={headerProps.handleLogout}
onToggleLoginModal={headerProps.handleToggleLoginModal}
scope={headerProps.scope}
username={headerProps.username}
/>
</Router>
);
@@ -126,6 +144,6 @@ describe('<Header /> component with logged in state', () => {
queryByTestId('registryInfo--dialog')
);
expect(hasRegistrationInfoModalBeenRemoved).toBeTruthy();
test.todo('autocompletion should display suggestions according to the type value');
});
test.todo('autocompletion should display suggestions according to the type value');
});

View File

@@ -1,11 +1,8 @@
import React, { useState, useContext } from 'react';
import React, { useState } from 'react';
import storage from '../../utils/storage';
import Search from '../Search';
import { getRegistryURL } from '../../utils/url';
import Button from '../../muiComponents/Button';
import AppContext from '../../App/AppContext';
import LoginDialog from '../LoginDialog';
import Search from '../Search';
import { NavBar, InnerNavBar, MobileNavBar, InnerMobileNavBar } from './styles';
import HeaderLeft from './HeaderLeft';
@@ -13,44 +10,31 @@ import HeaderRight from './HeaderRight';
import HeaderInfoDialog from './HeaderInfoDialog';
interface Props {
logo?: string;
username?: string;
onLogout: () => void;
onToggleLoginModal: () => void;
scope: string;
withoutSearch?: boolean;
}
/* eslint-disable react/jsx-max-depth */
/* eslint-disable react/jsx-no-bind*/
const Header: React.FC<Props> = ({ withoutSearch }) => {
const appContext = useContext(AppContext);
const Header: React.FC<Props> = ({ logo, withoutSearch, username, onLogout, onToggleLoginModal, scope }) => {
const [isInfoDialogOpen, setOpenInfoDialog] = useState();
const [showMobileNavBar, setShowMobileNavBar] = useState();
const [showLoginModal, setShowLoginModal] = useState(false);
if (!appContext) {
throw Error('The app Context was not correct used');
}
const { user, scope, setUser } = appContext;
const logo = window.VERDACCIO_LOGO;
/**
* Logouts user
* Required by: <Header />
*/
const handleLogout = () => {
storage.removeItem('username');
storage.removeItem('token');
setUser(undefined);
};
return (
<>
<NavBar data-testid="header" position="static">
<NavBar position="static">
<InnerNavBar>
<HeaderLeft logo={logo} />
<HeaderRight
onLogout={handleLogout}
onLogout={onLogout}
onOpenRegistryInfoDialog={() => setOpenInfoDialog(true)}
onToggleLogin={() => setShowLoginModal(!showLoginModal)}
onToggleLogin={onToggleLoginModal}
onToggleMobileNav={() => setShowMobileNavBar(!showMobileNavBar)}
username={user && user.username}
username={username}
withoutSearch={withoutSearch}
/>
</InnerNavBar>
@@ -71,7 +55,6 @@ const Header: React.FC<Props> = ({ withoutSearch }) => {
</Button>
</MobileNavBar>
)}
{!user && <LoginDialog onClose={() => setShowLoginModal(false)} open={showLoginModal} />}
</>
);
};

View File

@@ -11,7 +11,7 @@ interface Props {
const HeaderGreetings: React.FC<Props> = ({ username }) => (
<>
<Greetings>{'Hi,'}</Greetings>
<Label capitalize={true} data-testid="greetings-label" text={username} weight="bold" />
<Label capitalize={true} text={username} weight="bold" />
</>
);

View File

@@ -16,6 +16,7 @@ interface Props {
onLoggedInMenuClose: () => void;
}
/* eslint-disable react/jsx-max-depth */
const HeaderMenu: React.FC<Props> = ({
onLogout,
username,
@@ -27,7 +28,7 @@ const HeaderMenu: React.FC<Props> = ({
<>
<IconButton
color="inherit"
data-testid="header--menu-accountcircle"
data-testid="header--menu-acountcircle"
id="header--button-account"
onClick={onLoggedInMenu}>
<AccountCircle />
@@ -47,7 +48,7 @@ const HeaderMenu: React.FC<Props> = ({
<MenuItem disabled={true}>
<HeaderGreetings username={username} />
</MenuItem>
<MenuItem button={true} data-testid="header--button-logout" id="header--button-logout" onClick={onLogout}>
<MenuItem button={true} id="header--button-logout" onClick={onLogout}>
{'Logout'}
</MenuItem>
</Menu>

View File

@@ -53,7 +53,7 @@ const HeaderRight: React.FC<Props> = ({
};
return (
<RightSide data-testid="header-right">
<RightSide>
{!withoutSearch && (
<HeaderToolTip onClick={onToggleMobileNav} title={'Search packages'} tooltipIconType={'search'} />
)}

View File

@@ -141,7 +141,6 @@ exports[`<Header /> component with logged in state should load the component in
<header
class="MuiPaper-root MuiPaper-elevation4 MuiAppBar-root MuiAppBar-positionStatic emotion-24 emotion-25 MuiAppBar-colorPrimary"
data-testid="header"
>
<div
class="MuiToolbar-root MuiToolbar-regular emotion-22 emotion-23 MuiToolbar-gutters"
@@ -214,7 +213,6 @@ exports[`<Header /> component with logged in state should load the component in
</div>
<div
class="MuiToolbar-root MuiToolbar-regular emotion-20 emotion-21 MuiToolbar-gutters"
data-testid="header-right"
>
<button
class="MuiButtonBase-root MuiIconButton-root emotion-16 emotion-17 MuiIconButton-colorInherit"
@@ -304,7 +302,7 @@ exports[`<Header /> component with logged in state should load the component in
</button>
<button
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-colorInherit"
data-testid="header--menu-accountcircle"
data-testid="header--menu-acountcircle"
id="header--button-account"
tabindex="0"
type="button"
@@ -474,7 +472,6 @@ exports[`<Header /> component with logged in state should load the component in
<header
class="MuiPaper-root MuiPaper-elevation4 MuiAppBar-root MuiAppBar-positionStatic emotion-24 emotion-25 MuiAppBar-colorPrimary"
data-testid="header"
>
<div
class="MuiToolbar-root MuiToolbar-regular emotion-22 emotion-23 MuiToolbar-gutters"
@@ -547,7 +544,6 @@ exports[`<Header /> component with logged in state should load the component in
</div>
<div
class="MuiToolbar-root MuiToolbar-regular emotion-20 emotion-21 MuiToolbar-gutters"
data-testid="header-right"
>
<button
class="MuiButtonBase-root MuiIconButton-root emotion-16 emotion-17 MuiIconButton-colorInherit"

View File

@@ -2,6 +2,7 @@ import styled from '@emotion/styled';
import { css } from '@emotion/core';
import { Theme } from '../../design-tokens/theme';
import { breakpoints } from '../../utils/styles/media';
import IconButton from '../../muiComponents/IconButton';
import AppBar from '../../muiComponents/AppBar';
import Toolbar from '../../muiComponents/Toolbar';
@@ -53,12 +54,12 @@ export const SearchWrapper = styled('div')({
width: '100%',
});
export const NavBar = styled(AppBar)<{ theme?: Theme }>(({ theme }) => ({
backgroundColor: theme && theme.palette.primary.main,
export const NavBar = styled(AppBar)<{ theme?: Theme }>(props => ({
backgroundColor: props.theme && props.theme.palette.primary.main,
minHeight: 60,
display: 'flex',
justifyContent: 'center',
[`@media (min-width: ${theme && theme.breakPoints.medium}px)`]: css`
[`@media (min-width: ${breakpoints.medium}px)`]: css`
${SearchWrapper} {
display: flex;
}
@@ -69,12 +70,12 @@ export const NavBar = styled(AppBar)<{ theme?: Theme }>(({ theme }) => ({
display: none;
}
`,
[`@media (min-width: ${theme && theme.breakPoints.large}px)`]: css`
[`@media (min-width: ${breakpoints.large}px)`]: css`
${InnerNavBar} {
padding: 0 20px;
}
`,
[`@media (min-width: ${theme && theme.breakPoints.xlarge}px)`]: css`
[`@media (min-width: ${breakpoints.xlarge}px)`]: css`
${InnerNavBar} {
max-width: 1240px;
width: 100%;

View File

@@ -2,16 +2,16 @@ import React, { useContext } from 'react';
import styled from '@emotion/styled';
import { DetailContext } from '../../pages/Version';
import { fontWeight } from '../../utils/styles/sizes';
import Text from '../../muiComponents/Text';
import List from '../../muiComponents/List';
import { Theme } from '../../design-tokens/theme';
import InstallListItem, { DependencyManager } from './InstallListItem';
const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
textTransform: 'capitalize',
}));
});
const Install: React.FC = () => {
const detailContext = useContext(DetailContext);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import styled from '@emotion/styled';
import { Theme } from '../../design-tokens/theme';
import { fontWeight } from '../../utils/styles/sizes';
interface Props {
text: string;
@@ -14,8 +14,8 @@ interface WrapperProps {
weight: string;
}
const Wrapper = styled('div')<WrapperProps & { theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight[props.weight],
const Wrapper = styled('div')<WrapperProps>(props => ({
fontWeight: fontWeight[props.weight],
textTransform: props.capitalize ? 'capitalize' : 'none',
}));

View File

@@ -7,26 +7,20 @@ interface Props extends Pick<TextProps, 'variant'> {
external?: boolean;
className?: string;
to: string;
children?: React.ReactNode;
}
type LinkRef = HTMLAnchorElement;
/* eslint-disable verdaccio/jsx-spread */
const Link = React.forwardRef<LinkRef, Props>(function Link(
{ external, to, children, variant, className, ...props },
ref
) {
const Link: React.FC<Props> = ({ external, to, children, variant, className, ...props }) => {
const LinkTextContent = <Text variant={variant}>{children}</Text>;
return external ? (
<a className={className} href={to} ref={ref} rel="noopener noreferrer" target="_blank" {...props}>
<a className={className} href={to} rel="noopener noreferrer" target="_blank" {...props}>
{LinkTextContent}
</a>
) : (
<RouterLink className={className} innerRef={ref} to={to} {...props}>
<RouterLink className={className} to={to} {...props}>
{LinkTextContent}
</RouterLink>
);
});
};
export default Link;

View File

@@ -0,0 +1,127 @@
/**
* @prettier
*/
import React from 'react';
import { mount } from '../../utils/test-enzyme';
import LoginModal from './Login';
const eventUsername = {
target: {
value: 'xyz',
},
};
const eventPassword = {
target: {
value: '1234',
},
};
const event = {
preventDefault: jest.fn(),
};
describe('<LoginModal />', () => {
test('should load the component in default state', () => {
const wrapper = mount(<LoginModal />);
expect(wrapper.html()).toMatchSnapshot();
});
test('should load the component with props', () => {
const props = {
visibility: true,
error: {
type: 'error',
title: 'Error Title',
description: 'Error Description',
},
onCancel: () => {},
onSubmit: () => {},
};
const wrapper = mount(<LoginModal {...props} />);
expect(wrapper.html()).toMatchSnapshot();
});
test('onCancel: should close the login modal', () => {
const props = {
visibility: true,
error: {
type: 'error',
title: 'Error Title',
description: 'Error Description',
},
onCancel: jest.fn(),
onSubmit: () => {},
};
const wrapper = mount(<LoginModal {...props} />);
wrapper.find('button[id="login--form-cancel"]').simulate('click');
expect(props.onCancel).toHaveBeenCalled();
});
test('setCredentials - should set username and password in state', () => {
const props = {
visibility: true,
onCancel: () => {},
onSubmit: () => {},
};
const wrapper = mount(<LoginModal {...props} />);
const { setCredentials } = wrapper.instance();
expect(setCredentials('username', eventUsername)).toBeUndefined();
expect(wrapper.state('form').username.value).toEqual('xyz');
expect(setCredentials('password', eventPassword)).toBeUndefined();
expect(wrapper.state('form').password.value).toEqual('1234');
});
test('validateCredentials: should validate credentials', async () => {
const props = {
visibility: true,
onCancel: () => {},
onSubmit: jest.fn(),
};
const wrapper = mount(<LoginModal {...props} />);
const instance = wrapper.instance();
instance.submitCredentials = jest.fn();
const { handleValidateCredentials, setCredentials, submitCredentials } = instance;
expect(setCredentials('username', eventUsername)).toBeUndefined();
expect(wrapper.state('form').username.value).toEqual('xyz');
expect(setCredentials('password', eventPassword)).toBeUndefined();
expect(wrapper.state('form').password.value).toEqual('1234');
handleValidateCredentials(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(wrapper.state('form').username.pristine).toEqual(false);
expect(wrapper.state('form').password.pristine).toEqual(false);
expect(submitCredentials).toHaveBeenCalledTimes(1);
});
test('submitCredentials: should submit credentials', async () => {
const props = {
onSubmit: jest.fn(),
};
const wrapper = mount(<LoginModal {...props} />);
const { setCredentials, submitCredentials } = wrapper.instance();
expect(setCredentials('username', eventUsername)).toBeUndefined();
expect(wrapper.state('form').username.value).toEqual('xyz');
expect(setCredentials('password', eventPassword)).toBeUndefined();
expect(wrapper.state('form').password.value).toEqual('1234');
await submitCredentials();
expect(props.onSubmit).toHaveBeenCalledWith('xyz', '1234');
expect(wrapper.state('form').username.value).toEqual('');
expect(wrapper.state('form').username.pristine).toEqual(true);
expect(wrapper.state('form').password.value).toEqual('');
expect(wrapper.state('form').password.pristine).toEqual(true);
});
});

View File

@@ -0,0 +1,248 @@
import React, { Component } from 'react';
import ErrorIcon from '@material-ui/icons/Error';
import Button from '../../muiComponents/Button';
import Dialog from '../../muiComponents/Dialog';
import DialogTitle from '../../muiComponents/DialogTitle';
import DialogContent from '../../muiComponents/DialogContent';
import DialogActions from '../../muiComponents/DialogActions';
import FormControl from '../../muiComponents/FormControl';
import FormHelperText from '../../muiComponents/FormHelperText';
import Input from '../../muiComponents/Input';
import InputLabel from '../../muiComponents/InputLabel';
import SnackbarContent from '../../muiComponents/SnackbarContent';
import * as classes from './styles';
interface FormFields {
required: boolean;
pristine: boolean;
helperText: string;
value: string;
}
export interface FormError {
type: string;
title: string;
description: string;
}
interface LoginModalProps {
visibility: boolean;
error?: FormError;
onCancel: () => void;
onSubmit: (username: string, password: string) => void;
}
interface LoginModalState {
form: {
username: Partial<FormFields>;
password: Partial<FormFields>;
};
error?: FormError;
}
export default class LoginModal extends Component<Partial<LoginModalProps>, LoginModalState> {
constructor(props: LoginModalProps) {
super(props);
this.state = {
form: {
username: {
required: true,
pristine: true,
helperText: 'Field required',
value: '',
},
password: {
required: true,
pristine: true,
helperText: 'Field required',
value: '',
},
},
error: props.error,
};
}
public render(): JSX.Element {
const { visibility = true, onCancel = () => null, error } = this.props as LoginModalProps;
return (
<Dialog fullWidth={true} id={'login--form-container'} maxWidth={'xs'} onClose={onCancel} open={visibility}>
<form noValidate={true} onSubmit={this.handleValidateCredentials}>
<DialogTitle>{'Login'}</DialogTitle>
<DialogContent>
{error && this.renderLoginError(error)}
{this.renderNameField()}
{this.renderPasswordField()}
</DialogContent>
{this.renderActions()}
</form>
</Dialog>
);
}
/**
* set login modal's username and password to current state
* Required to login
*/
public setCredentials = (name, e) => {
const { form } = this.state;
this.setState({
form: {
...form,
[name]: {
...form[name],
value: e.target.value,
pristine: false,
},
},
});
};
public handleUsernameChange = event => {
this.setCredentials('username', event);
};
public handlePasswordChange = event => {
this.setCredentials('password', event);
};
public handleValidateCredentials = event => {
const { form } = this.state;
// prevents default submit behavior
event.preventDefault();
this.setState(
{
form: Object.keys(form).reduce(
(acc, key) => ({
...acc,
...{ [key]: { ...form[key], pristine: false } },
}),
{ username: {}, password: {} }
),
},
() => {
if (!Object.keys(form).some(id => !form[id])) {
this.submitCredentials();
}
}
);
};
public submitCredentials = async () => {
const { form } = this.state;
const username = (form.username && form.username.value) || '';
const password = (form.password && form.password.value) || '';
const { onSubmit } = this.props;
if (onSubmit) {
await onSubmit(username, password);
}
// let's wait for API response and then set
// username and password filed to empty state
this.setState({
form: Object.keys(form).reduce(
(acc, key) => ({
...acc,
...{ [key]: { ...form[key], value: '', pristine: true } },
}),
{ username: {}, password: {} }
),
});
};
public renderErrorMessage(title: string, description: string): JSX.Element {
return (
<span>
<div>
<strong>{title}</strong>
</div>
<div>{description}</div>
</span>
);
}
public renderMessage(title: string, description: string): JSX.Element {
return (
<div className={classes.loginErrorMsg} id={'client-snackbar'}>
<ErrorIcon className={classes.loginIcon} />
{this.renderErrorMessage(title, description)}
</div>
);
}
public renderLoginError({ type, title, description }: FormError): JSX.Element | false {
return (
type === 'error' && (
<SnackbarContent className={classes.loginError} message={this.renderMessage(title, description)} />
)
);
}
public renderNameField = () => {
const {
form: { username },
} = this.state;
return (
<FormControl error={!username.value && !username.pristine} fullWidth={true} required={username.required}>
<InputLabel htmlFor={'username'}>{'Username'}</InputLabel>
<Input
id={'login--form-username'}
onChange={this.handleUsernameChange}
placeholder={'Your username'}
value={username.value}
/>
{!username.value && !username.pristine && (
<FormHelperText id={'username-error'}>{username.helperText}</FormHelperText>
)}
</FormControl>
);
};
public renderPasswordField = () => {
const {
form: { password },
} = this.state;
return (
<FormControl
// className={css`
// margin-top: 8px;
// `}
error={!password.value && !password.pristine}
fullWidth={true}
required={password.required}>
<InputLabel htmlFor={'password'}>{'Password'}</InputLabel>
<Input
id={'login--form-password'}
onChange={this.handlePasswordChange}
placeholder={'Your strong password'}
type={'password'}
value={password.value}
/>
{!password.value && !password.pristine && (
<FormHelperText id={'password-error'}>{password.helperText}</FormHelperText>
)}
</FormControl>
);
};
public renderActions = () => {
const {
form: { username, password },
} = this.state;
const { onCancel } = this.props;
return (
<DialogActions className={'dialog-footer'}>
<Button color={'inherit'} id={'login--form-cancel'} onClick={onCancel} type={'button'}>
{'Cancel'}
</Button>
<Button
color={'inherit'}
disabled={!password.value || !username.value}
id={'login--form-submit'}
type={'submit'}>
{'Login'}
</Button>
</DialogActions>
);
};
}

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginModal /> should load the component in default state 1`] = `"<div role=\\"presentation\\" class=\\"MuiDialog-root\\" id=\\"login--form-container\\" style=\\"position: fixed; z-index: 1300; right: 0px; bottom: 0px; top: 0px; left: 0px;\\"><div class=\\"MuiBackdrop-root\\" aria-hidden=\\"true\\" style=\\"opacity: 1; webkit-transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;\\"></div><div tabindex=\\"0\\" data-test=\\"sentinelStart\\"></div><div class=\\"MuiDialog-container MuiDialog-scrollPaper\\" style=\\"opacity: 1; webkit-transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;\\" role=\\"none presentation\\" tabindex=\\"-1\\"><div class=\\"MuiPaper-root MuiPaper-elevation24 MuiDialog-paper MuiDialog-paperScrollPaper MuiDialog-paperWidthXs MuiDialog-paperFullWidth MuiPaper-rounded\\" role=\\"dialog\\"><form novalidate=\\"\\"><div class=\\"MuiDialogTitle-root\\"><h2 class=\\"MuiTypography-root MuiTypography-h6\\">Login</h2></div><div class=\\"MuiDialogContent-root\\"><div class=\\"MuiFormControl-root MuiFormControl-fullWidth\\"><label class=\\"MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated Mui-required Mui-required\\" data-shrink=\\"false\\" for=\\"username\\">Username<span class=\\"MuiFormLabel-asterisk MuiInputLabel-asterisk\\"> *</span></label><div class=\\"MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-formControl MuiInput-formControl\\"><input aria-invalid=\\"false\\" id=\\"login--form-username\\" placeholder=\\"Your username\\" required=\\"\\" type=\\"text\\" class=\\"MuiInputBase-input MuiInput-input\\" value=\\"\\"></div></div><div class=\\"MuiFormControl-root MuiFormControl-fullWidth\\"><label class=\\"MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated Mui-required Mui-required\\" data-shrink=\\"false\\" for=\\"password\\">Password<span class=\\"MuiFormLabel-asterisk MuiInputLabel-asterisk\\"> *</span></label><div class=\\"MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-formControl MuiInput-formControl\\"><input aria-invalid=\\"false\\" id=\\"login--form-password\\" placeholder=\\"Your strong password\\" required=\\"\\" type=\\"password\\" class=\\"MuiInputBase-input MuiInput-input\\" value=\\"\\"></div></div></div><div class=\\"MuiDialogActions-root dialog-footer MuiDialogActions-spacing\\"><button class=\\"MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-colorInherit\\" tabindex=\\"0\\" type=\\"button\\" id=\\"login--form-cancel\\"><span class=\\"MuiButton-label\\">Cancel</span><span class=\\"MuiTouchRipple-root\\"></span></button><button class=\\"MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-colorInherit Mui-disabled Mui-disabled\\" tabindex=\\"-1\\" type=\\"submit\\" disabled=\\"\\" id=\\"login--form-submit\\"><span class=\\"MuiButton-label\\">Login</span></button></div></form></div></div><div tabindex=\\"0\\" data-test=\\"sentinelEnd\\"></div></div>"`;
exports[`<LoginModal /> should load the component with props 1`] = `"<div role=\\"presentation\\" class=\\"MuiDialog-root\\" id=\\"login--form-container\\" style=\\"position: fixed; z-index: 1300; right: 0px; bottom: 0px; top: 0px; left: 0px;\\"><div class=\\"MuiBackdrop-root\\" aria-hidden=\\"true\\" style=\\"opacity: 1; webkit-transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;\\"></div><div tabindex=\\"0\\" data-test=\\"sentinelStart\\"></div><div class=\\"MuiDialog-container MuiDialog-scrollPaper\\" style=\\"opacity: 1; webkit-transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;\\" role=\\"none presentation\\" tabindex=\\"-1\\"><div class=\\"MuiPaper-root MuiPaper-elevation24 MuiDialog-paper MuiDialog-paperScrollPaper MuiDialog-paperWidthXs MuiDialog-paperFullWidth MuiPaper-rounded\\" role=\\"dialog\\"><form novalidate=\\"\\"><div class=\\"MuiDialogTitle-root\\"><h2 class=\\"MuiTypography-root MuiTypography-h6\\">Login</h2></div><div class=\\"MuiDialogContent-root\\"><div class=\\"MuiTypography-root MuiPaper-root MuiPaper-elevation6 MuiSnackbarContent-root css-xlgaf-loginError MuiTypography-body2\\" role=\\"alert\\"><div class=\\"MuiSnackbarContent-message\\"><div class=\\"css-vvv32-loginErrorMsg\\" id=\\"client-snackbar\\"><svg class=\\"MuiSvgIcon-root css-tkvt8h-loginIcon\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path d=\\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z\\"></path></svg><span><div><strong>Error Title</strong></div><div>Error Description</div></span></div></div></div><div class=\\"MuiFormControl-root MuiFormControl-fullWidth\\"><label class=\\"MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated Mui-required Mui-required\\" data-shrink=\\"false\\" for=\\"username\\">Username<span class=\\"MuiFormLabel-asterisk MuiInputLabel-asterisk\\"> *</span></label><div class=\\"MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-formControl MuiInput-formControl\\"><input aria-invalid=\\"false\\" id=\\"login--form-username\\" placeholder=\\"Your username\\" required=\\"\\" type=\\"text\\" class=\\"MuiInputBase-input MuiInput-input\\" value=\\"\\"></div></div><div class=\\"MuiFormControl-root MuiFormControl-fullWidth\\"><label class=\\"MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated Mui-required Mui-required\\" data-shrink=\\"false\\" for=\\"password\\">Password<span class=\\"MuiFormLabel-asterisk MuiInputLabel-asterisk\\"> *</span></label><div class=\\"MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-formControl MuiInput-formControl\\"><input aria-invalid=\\"false\\" id=\\"login--form-password\\" placeholder=\\"Your strong password\\" required=\\"\\" type=\\"password\\" class=\\"MuiInputBase-input MuiInput-input\\" value=\\"\\"></div></div></div><div class=\\"MuiDialogActions-root dialog-footer MuiDialogActions-spacing\\"><button class=\\"MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-colorInherit\\" tabindex=\\"0\\" type=\\"button\\" id=\\"login--form-cancel\\"><span class=\\"MuiButton-label\\">Cancel</span><span class=\\"MuiTouchRipple-root\\"></span></button><button class=\\"MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-colorInherit Mui-disabled Mui-disabled\\" tabindex=\\"-1\\" type=\\"submit\\" disabled=\\"\\" id=\\"login--form-submit\\"><span class=\\"MuiButton-label\\">Login</span></button></div></form></div></div><div tabindex=\\"0\\" data-test=\\"sentinelEnd\\"></div></div>"`;

View File

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

View File

@@ -0,0 +1,23 @@
import { css } from 'emotion';
import { theme } from '../../design-tokens/theme';
export const loginDialog = css({
minWidth: '300px',
});
export const loginError = css({
backgroundColor: `${theme.palette.red} !important`,
minWidth: 'inherit !important',
marginBottom: '10px !important',
});
export const loginErrorMsg = css({
display: 'flex',
alignItems: 'center',
});
export const loginIcon = css({
opacity: 0.9,
marginRight: '8px',
});

View File

@@ -1,106 +0,0 @@
import React from 'react';
import { render, waitForElement, fireEvent, waitForElementToBeRemoved } from '../../utils/test-react-testing-library';
import AppContext, { AppContextProps } from '../../App/AppContext';
import api from '../../utils/api';
import LoginDialog from './LoginDialog';
const appContextValue: AppContextProps = {
scope: '',
packages: [],
setUser: jest.fn(),
};
describe('<LoginDialog /> component', () => {
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
});
test('should render the component in default state', () => {
const props = {
onClose: jest.fn(),
};
const { container } = render(
<AppContext.Provider value={appContextValue}>
<LoginDialog onClose={props.onClose} />
</AppContext.Provider>
);
expect(container.firstChild).toMatchSnapshot();
});
test('should load the component with the open prop', async () => {
const props = {
open: true,
onClose: jest.fn(),
};
const { getByText } = render(
<AppContext.Provider value={appContextValue}>
<LoginDialog onClose={props.onClose} open={props.open} />
</AppContext.Provider>
);
const loginDialogHeading = await waitForElement(() => getByText('Sign in'));
expect(loginDialogHeading).toBeTruthy();
});
test('onClose: should close the login modal', async () => {
const props = {
open: true,
onClose: jest.fn(),
};
const { getByTestId } = render(
<AppContext.Provider value={appContextValue}>
<LoginDialog onClose={props.onClose} open={props.open} />
</AppContext.Provider>
);
const loginDialogButton = await waitForElement(() => getByTestId('close-login-dialog-button'));
expect(loginDialogButton).toBeTruthy();
fireEvent.click(loginDialogButton, { open: false });
expect(props.onClose).toHaveBeenCalled();
});
// TODO
test.skip('setCredentials - should set username and password in state', async () => {
const props = {
open: true,
onClose: jest.fn(),
};
jest.spyOn(api, 'request').mockImplementation(() =>
Promise.resolve({
username: 'xyz',
token: 'djsadaskljd',
})
);
const { getByPlaceholderText, getByText } = render(
<AppContext.Provider value={appContextValue}>
<LoginDialog onClose={props.onClose} open={props.open} />
</AppContext.Provider>
);
// TODO: the input's value is not being updated in the DOM
const userNameInput = getByPlaceholderText('Your username');
fireEvent.focus(userNameInput);
fireEvent.change(userNameInput, { target: { value: 'xyz' } });
// TODO: the input's value is not being updated in the DOM
const passwordInput = getByPlaceholderText('Your strong password');
fireEvent.focus(passwordInput);
fireEvent.change(passwordInput, { target: { value: '1234' } });
// TODO: submitting form does not work
const signInButton = getByText('Sign in');
fireEvent.click(signInButton);
});
test.todo('validateCredentials: should validate credentials');
test.todo('submitCredentials: should submit credentials');
});

View File

@@ -1,56 +0,0 @@
import React, { useState, useContext, useCallback } from 'react';
import { makeLogin } from '../../utils/login';
import storage from '../../utils/storage';
import Dialog from '../../muiComponents/Dialog';
import DialogContent from '../../muiComponents/DialogContent';
import AppContext from '../../App/AppContext';
import LoginDialogCloseButton from './LoginDialogCloseButton';
import LoginDialogForm, { FormValues } from './LoginDialogForm';
import LoginDialogHeader from './LoginDialogHeader';
interface Props {
open?: boolean;
onClose: () => void;
}
const LoginDialog: React.FC<Props> = ({ onClose, open = false }) => {
const appContext = useContext(AppContext);
if (!appContext) {
throw Error('The app Context was not correct used');
}
const [error, setError] = useState();
const handleDoLogin = useCallback(
async (data: FormValues) => {
const { username, token, error } = await makeLogin(data.username, data.password);
if (error) {
setError(error);
}
if (username && token) {
storage.setItem('username', username);
storage.setItem('token', token);
appContext.setUser({ username });
onClose();
}
},
[appContext, onClose]
);
return (
<Dialog fullWidth={true} id="login--dialog" maxWidth="sm" onClose={onClose} open={open}>
<LoginDialogCloseButton onClose={onClose} />
<DialogContent>
<LoginDialogHeader />
<LoginDialogForm error={error} onSubmit={handleDoLogin} />
</DialogContent>
</Dialog>
);
};
export default LoginDialog;

View File

@@ -1,28 +0,0 @@
import React from 'react';
import styled from '@emotion/styled';
import CloseIcon from '@material-ui/icons/Close';
import DialogTitle from '../../muiComponents/DialogTitle';
import IconButton from '../../muiComponents/IconButton';
import { Theme } from '../../design-tokens/theme';
const StyledIconButton = styled(IconButton)<{ theme?: Theme }>(({ theme }) => ({
position: 'absolute',
right: theme.spacing() / 2,
top: theme.spacing() / 2,
color: theme.palette.grey[500],
}));
interface Props {
onClose: () => void;
}
const LoginDialogCloseButton: React.FC<Props> = ({ onClose }) => (
<DialogTitle>
<StyledIconButton data-testid="close-login-dialog-button" onClick={onClose}>
<CloseIcon titleAccess="Close Dialog" />
</StyledIconButton>
</DialogTitle>
);
export default LoginDialogCloseButton;

View File

@@ -1,94 +0,0 @@
import React, { memo } from 'react';
import styled from '@emotion/styled';
import useForm from 'react-hook-form/dist/react-hook-form.ie11';
import TextField from '../../muiComponents/TextField';
import Button from '../../muiComponents/Button';
import { Theme } from '../../design-tokens/theme';
import { LoginError } from '../../utils/login';
import LoginDialogFormError from './LoginDialogFormError';
const StyledForm = styled('form')<{ theme?: Theme }>(({ theme }) => ({
marginTop: theme.spacing(1),
}));
const StyledButton = styled(Button)<{ theme?: Theme }>(({ theme }) => ({
margin: theme.spacing(3, 0, 2),
}));
export interface FormValues {
username: string;
password: string;
}
interface Props {
onSubmit: (formValues: FormValues) => void;
error?: LoginError;
}
const LoginDialogForm = memo(({ onSubmit, error }: Props) => {
const {
register,
errors,
handleSubmit,
formState: { isValid },
} = useForm<FormValues>({ mode: 'onChange' });
const onSubmitForm = (formValues: FormValues) => {
onSubmit(formValues);
};
return (
<StyledForm noValidate={true} onSubmit={handleSubmit(onSubmitForm)}>
<TextField
autoComplete="username"
error={!!errors.username}
fullWidth={true}
helperText={errors.username?.message}
id="login--dialog-username"
inputRef={register({
required: { value: true, message: 'This field is required' },
minLength: { value: 2, message: 'This field required the min length of 2' },
})}
label="Username"
margin="normal"
name="username"
placeholder="Your username"
required={true}
variant="outlined"
/>
<TextField
autoComplete="current-password"
error={!!errors.password}
fullWidth={true}
helperText={errors.password?.message}
id="login--dialog-password"
inputRef={register({
required: { value: true, message: 'This field is required' },
minLength: { value: 2, message: 'This field required the min length of 2' },
})}
label="Password"
margin="normal"
name="password"
placeholder="Your strong password"
required={true}
type="password"
variant="outlined"
/>
{error && <LoginDialogFormError error={error} />}
<StyledButton
color="primary"
disabled={!isValid}
fullWidth={true}
id="login--dialog-button-submit"
size="large"
type="submit"
variant="contained">
{'Sign In'}
</StyledButton>
</StyledForm>
);
});
export default LoginDialogForm;

View File

@@ -1,42 +0,0 @@
import React, { memo } from 'react';
import styled from '@emotion/styled';
import Error from '@material-ui/icons/Error';
import SnackbarContent from '../../muiComponents/SnackbarContent';
import Box from '../../muiComponents/Box';
import { Theme } from '../../design-tokens/theme';
import { LoginError } from '../../utils/login';
const StyledSnackbarContent = styled(SnackbarContent)<{ theme?: Theme }>(({ theme }) => ({
backgroundColor: theme.palette.error.dark,
}));
const StyledErrorIcon = styled(Error)<{ theme?: Theme }>(({ theme }) => ({
fontSize: 20,
opacity: 0.9,
marginRight: theme.spacing(1),
}));
export interface FormValues {
username: string;
password: string;
}
interface Props {
error: LoginError;
}
const LoginDialogFormError = memo(({ error }: Props) => {
return (
<StyledSnackbarContent
message={
<Box alignItems="center" display="flex">
<StyledErrorIcon />
{error.description}
</Box>
}
/>
);
});
export default LoginDialogFormError;

View File

@@ -1,44 +0,0 @@
import React from 'react';
import styled from '@emotion/styled';
import LockOutlined from '@material-ui/icons/LockOutlined';
import CloseIcon from '@material-ui/icons/Close';
import Heading from '../../muiComponents/Heading';
import Avatar from '../../muiComponents/Avatar';
import Box from '../../muiComponents/Box';
import IconButton from '../../muiComponents/IconButton';
import { Theme } from '../../design-tokens/theme';
const StyledAvatar = styled(Avatar)<{ theme?: Theme }>(({ theme }) => ({
margin: theme.spacing(1),
backgroundColor: theme.palette.primary.main,
}));
const StyledIconButton = styled(IconButton)<{ theme?: Theme }>(({ theme }) => ({
position: 'absolute',
right: theme.spacing() / 2,
top: theme.spacing() / 2,
color: theme.palette.grey[500],
}));
interface Props {
onClose?: () => void;
}
const LoginDialogHeader: React.FC<Props> = ({ onClose }) => {
return (
<Box alignItems="center" display="flex" flexDirection="column" position="relative">
{onClose && (
<StyledIconButton aria-label="Close" onClick={onClose}>
<CloseIcon />
</StyledIconButton>
)}
<StyledAvatar>
<LockOutlined />
</StyledAvatar>
<Heading>{'Sign in'}</Heading>
</Box>
);
};
export default LoginDialogHeader;

View File

@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginDialog /> component should render the component in default state 1`] = `null`;

View File

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

View File

@@ -5,6 +5,7 @@ import { useHistory } from 'react-router-dom';
import Box from '../../muiComponents/Box';
import Button from '../../muiComponents/Button';
import Heading from '../../muiComponents/Heading';
import { spacings } from '../../utils/styles/spacings';
import { Theme } from '../../design-tokens/theme';
import PackageImg from './img/package.svg';
@@ -20,7 +21,7 @@ const EmptyPackage = styled('img')({
const StyledHeading = styled(Heading)<{ theme?: Theme }>(props => ({
color: props.theme && props.theme.palette.primary.main,
marginBottom: 16,
marginBottom: spacings.sm,
}));
const NotFound: React.FC = () => {

View File

@@ -9,7 +9,7 @@ import fileSizeSI from '../../utils/file-size';
import { formatDate, formatDateDistance } from '../../utils/package';
import Tooltip from '../../muiComponents/Tooltip';
import { isURL } from '../../utils/url';
import { downloadTarball } from '../ActionBar';
import { downloadHandler } from '../ActionBar/ActionBar';
import ListItem from '../../muiComponents/ListItem';
import Grid from '../../muiComponents/Grid';
@@ -104,7 +104,7 @@ const Package: React.FC<PackageInterface> = ({
<OverviewItem>
<Icon name="time" />
<Published>{`Published on ${formatDate(time)} •`}</Published>
{formatDateDistance(time)}
{`${formatDateDistance(time)} ago`}
</OverviewItem>
);
@@ -114,6 +114,7 @@ const Package: React.FC<PackageInterface> = ({
<a href={homepage} target={'_blank'}>
<Tooltip aria-label={'Homepage'} title={'Visit homepage'}>
<IconButton aria-label={'Homepage'}>
{/* eslint-disable-next-line react/jsx-max-depth */}
<HomeIcon />
</IconButton>
</Tooltip>
@@ -127,6 +128,7 @@ const Package: React.FC<PackageInterface> = ({
<a href={bugs.url} target={'_blank'}>
<Tooltip aria-label={'Bugs'} title={'Open an issue'}>
<IconButton aria-label={'Bugs'}>
{/* eslint-disable-next-line react/jsx-max-depth */}
<BugReport />
</IconButton>
</Tooltip>
@@ -138,9 +140,10 @@ const Package: React.FC<PackageInterface> = ({
dist.tarball &&
isURL(dist.tarball) && (
// eslint-disable-next-line
<a onClick={downloadTarball(dist.tarball.replace(`https://registry.npmjs.org/`, window.location.href))} target={'_blank'}>
<a onClick={() => downloadHandler(dist.tarball.replace(`https://registry.npmjs.org/`, window.location.href))} target={'_blank'}>
<Tooltip aria-label={'Download the tar file'} title={'Download tarball'}>
<IconButton aria-label={'Download'}>
{/* eslint-disable-next-line react/jsx-max-depth */}
<DownloadIcon />
</IconButton>
</Tooltip>
@@ -152,6 +155,7 @@ const Package: React.FC<PackageInterface> = ({
<Grid container={true} item={true} xs={12}>
<Grid item={true} xs={true}>
<WrapperLink to={`/-/web/detail/${packageName}`}>
{/* eslint-disable-next-line react/jsx-max-depth */}
<PackageTitle className="package-title">{packageName}</PackageTitle>
</WrapperLink>
</Grid>

View File

@@ -1,8 +1,10 @@
import styled from '@emotion/styled';
import { Link } from 'react-router-dom';
import { breakpoints } from '../../utils/styles/media';
import Ico from '../Icon';
import Label from '../Label';
import { fontWeight } from '../../utils/styles/sizes';
import { default as MuiIconButton } from '../../muiComponents/IconButton';
import { default as Photo } from '../../muiComponents/Avatar';
import List from '../../muiComponents/List';
@@ -11,18 +13,18 @@ import Grid from '../../muiComponents/Grid';
import ListItemText from '../../muiComponents/ListItemText';
import { Theme } from '../../design-tokens/theme';
export const OverviewItem = styled('span')<{ theme?: Theme }>(({ theme }) => ({
export const OverviewItem = styled('span')<{ theme?: Theme }>(props => ({
display: 'flex',
alignItems: 'center',
margin: '0 0 0 16px',
color: theme && theme.palette.greyLight2,
color: props.theme && props.theme.palette.greyLight2,
fontSize: 12,
[`@media (max-width: ${theme && theme.breakPoints.medium}px)`]: {
[`@media (max-width: ${breakpoints.medium}px)`]: {
':nth-of-type(3)': {
display: 'none',
},
},
[`@media (max-width: ${theme && theme.breakPoints.small}px)`]: {
[`@media (max-width: ${breakpoints.small}px)`]: {
':nth-of-type(4)': {
display: 'none',
},
@@ -41,7 +43,7 @@ export const Published = styled('span')<{ theme?: Theme }>(props => ({
export const Text = styled(Label)<{ theme?: Theme }>(props => ({
fontSize: '12px',
fontWeight: props.theme && props.theme.fontWeight.semiBold,
fontWeight: fontWeight.semiBold,
color: props.theme && props.theme.palette.greyLight2,
}));
@@ -66,17 +68,17 @@ export const WrapperLink = styled(Link)({
textDecoration: 'none',
});
export const PackageTitle = styled('span')<{ theme?: Theme }>(({ theme }) => ({
fontWeight: theme && theme.fontWeight.bold,
export const PackageTitle = styled('span')<{ theme?: Theme }>(props => ({
fontWeight: 600,
fontSize: 20,
display: 'block',
marginBottom: 12,
color: theme && theme.palette.eclipse,
color: props.theme && props.theme.palette.eclipse,
cursor: 'pointer',
':hover': {
color: theme && theme.palette.black,
color: props.theme && props.theme.palette.black,
},
[`@media (max-width: ${theme && theme.breakPoints.small}px)`]: {
[`@media (max-width: ${breakpoints.small}px)`]: {
fontSize: 14,
marginBottom: 8,
},
@@ -103,14 +105,14 @@ export const IconButton = styled(MuiIconButton)({
},
});
export const TagContainer = styled('span')<{ theme?: Theme }>(({ theme }) => ({
export const TagContainer = styled('span')({
marginTop: 8,
marginBottom: 12,
display: 'block',
[`@media (max-width: ${theme && theme.breakPoints.medium}px)`]: {
[`@media (max-width: ${breakpoints.medium}px)`]: {
display: 'none',
},
}));
});
export const PackageListItemText = styled(ListItemText)({
paddingRight: 0,

View File

@@ -1,5 +1,4 @@
import React, { Fragment, ReactNode } from 'react';
import styled from '@emotion/styled';
import Package from '../Package';
import Help from '../Help';
@@ -7,10 +6,7 @@ import { formatLicense } from '../../utils/package';
import { PackageInterface } from '../Package/Package';
import Divider from '../../muiComponents/Divider';
const PkgContainer = styled('div')({
margin: 0,
padding: 0,
});
import * as classes from './styles';
interface Props {
packages: PackageInterface[];
@@ -36,7 +32,7 @@ export const PackageList: React.FC<Props> = ({ packages }) => {
return (
<div className={'package-list-items'}>
<PkgContainer>{hasPackages() ? renderPackages() : <Help />}</PkgContainer>
<div className={classes.pkgContainer}>{hasPackages() ? renderPackages() : <Help />}</div>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import { css } from 'emotion';
import { fontWeight, fontSize } from '../../utils/styles/sizes';
export const listTitle = css({
fontWeight: fontWeight.regular,
fontSize: fontSize.xl,
margin: `0 0 10px 0`,
});
export const pkgContainer = css`
margin: 0;
padding: 0;
& .listTitle {
${listTitle}
}
`;

View File

@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { fontSize } from '../../utils/styles/sizes';
import DialogTitle from '../../muiComponents/DialogTitle';
import DialogContent from '../../muiComponents/DialogContent';
import { Theme } from '../../design-tokens/theme';
@@ -7,7 +8,7 @@ import { Theme } from '../../design-tokens/theme';
export const Title = styled(DialogTitle)<{ theme?: Theme }>(props => ({
backgroundColor: props.theme && props.theme.palette.primary.main,
color: props.theme && props.theme.palette.white,
fontSize: props.theme && props.theme.fontSize.lg,
fontSize: fontSize.lg,
}));
export const Content = styled(DialogContent)({

View File

@@ -1,58 +1,64 @@
import React from 'react';
import { render } from '../../utils/test-react-testing-library';
import { DetailContext, DetailContextProps } from '../../pages/Version';
import { mount } from '../../utils/test-enzyme';
import Repository from './Repository';
import data from './__partials__/data.json';
const detailContextValue: DetailContextProps = {
packageName: 'foo',
readMe: 'readMe',
enableLoading: () => {},
isLoading: false,
hasNotBeenFound: false,
packageMeta: data,
};
jest.mock('./img/git.png', () => '');
const ComponentToBeRendered: React.FC<{ contextValue: DetailContextProps }> = ({ contextValue }) => (
<DetailContext.Provider value={contextValue}>
<Repository />
</DetailContext.Provider>
);
const mockPackageMeta: jest.Mock = jest.fn(() => ({
latest: {
homepage: 'https://verdaccio.tld',
bugs: {
url: 'https://verdaccio.tld/bugs',
},
dist: {
tarball: 'https://verdaccio.tld/download',
},
},
}));
jest.mock('../../pages/Version', () => ({
DetailContextConsumer: component => {
return component.children({ packageMeta: mockPackageMeta() });
},
}));
describe('<Repository /> component', () => {
test('should load the component in default state', () => {
const { container } = render(<ComponentToBeRendered contextValue={detailContextValue} />);
expect(container.firstChild).toMatchSnapshot();
beforeEach(() => {
jest.resetAllMocks();
});
test('should render the component in default state', () => {
const packageMeta = {
latest: {
repository: {
type: 'git',
url: 'git+https://github.com/verdaccio/ui.git',
},
},
};
mockPackageMeta.mockImplementation(() => packageMeta);
const wrapper = mount(<Repository />);
expect(wrapper.html()).toMatchSnapshot();
});
test('should render the component in with no repository data', () => {
const packageMeta = {
...detailContextValue.packageMeta,
latest: {
...detailContextValue.packageMeta?.latest,
repository: undefined,
},
latest: {},
};
const { queryByText } = render(
<ComponentToBeRendered
contextValue={{
...detailContextValue,
packageMeta,
}}
/>
);
mockPackageMeta.mockImplementation(() => packageMeta);
expect(queryByText('Repository')).toBeFalsy();
const wrapper = mount(<Repository />);
expect(wrapper.html()).toEqual('');
});
test('should render the component in with invalid url', () => {
const packageMeta = {
...detailContextValue.packageMeta,
latest: {
...detailContextValue.packageMeta?.latest,
repository: {
type: 'git',
url: 'git://github.com/verdaccio/ui.git',
@@ -60,15 +66,9 @@ describe('<Repository /> component', () => {
},
};
const { queryByText } = render(
<ComponentToBeRendered
contextValue={{
...detailContextValue,
packageMeta,
}}
/>
);
mockPackageMeta.mockImplementation(() => packageMeta);
expect(queryByText('Repository')).toBeFalsy();
const wrapper = mount(<Repository />);
expect(wrapper.html()).toEqual('');
});
});

View File

@@ -1,76 +1,57 @@
import React from 'react';
import styled from '@emotion/styled';
/* eslint react/jsx-max-depth: 0 */
import React, { Component, Fragment, ReactElement } from 'react';
import Avatar from '../../muiComponents/Avatar';
import Text from '../../muiComponents/Text';
import ListItem from '../../muiComponents/ListItem';
import ListItemText from '../../muiComponents/ListItemText';
import { DetailContextConsumer } from '../../pages/Version';
import { isURL } from '../../utils/url';
import CopyToClipBoard from '../CopyToClipBoard';
import List from '../../muiComponents/List';
import { DetailContext } from '../../pages/Version';
import { Theme } from '../../design-tokens/theme';
import git from './img/git.png';
import { GithubLink, StyledText, RepositoryListItem, RepositoryListItemText } from './styles';
const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
textTransform: 'capitalize',
}));
const GithubLink = styled('a')<{ theme?: Theme }>(props => ({
color: props.theme && props.theme.palette.primary.main,
}));
const RepositoryListItem = styled(ListItem)({
padding: 0,
':hover': {
backgroundColor: 'transparent',
},
});
const RepositoryListItemText = styled(ListItemText)({
padding: '0 10px',
margin: 0,
});
const Repository: React.FC = () => {
const detailContext = React.useContext(DetailContext);
const { packageMeta } = detailContext;
if (!packageMeta?.latest?.repository?.url || !isURL(packageMeta.latest.repository.url)) {
return null;
class Repository extends Component {
public render(): ReactElement<HTMLElement> {
return (
<DetailContextConsumer>
{context => {
return context && context.packageMeta && this.renderRepository(context.packageMeta);
}}
</DetailContextConsumer>
);
}
const { url } = packageMeta.latest.repository;
private renderRepositoryText(url: string): ReactElement<HTMLElement> {
return (
<GithubLink href={url} target="_blank">
{url}
</GithubLink>
);
}
const getCorrectRepositoryURL = (): string => {
if (!url.includes('git+')) {
return url;
private renderRepository = packageMeta => {
const { repository: { url = null } = {} } = packageMeta.latest;
if (!url || isURL(url) === false) {
return null;
}
return url.split('git+')[1];
return (
<Fragment>
<List dense={true} subheader={<StyledText variant="subtitle1">{'Repository'}</StyledText>}>
<RepositoryListItem button={true}>
<Avatar src={git} />
<RepositoryListItemText primary={this.renderContent(url)} />
</RepositoryListItem>
</List>
</Fragment>
);
};
const repositoryURL = getCorrectRepositoryURL();
return (
<List dense={true} subheader={<StyledText variant="subtitle1">{'Repository'}</StyledText>}>
<RepositoryListItem button={true}>
<Avatar src={git} />
<RepositoryListItemText
primary={
<CopyToClipBoard text={repositoryURL}>
<GithubLink href={repositoryURL} target="_blank">
{repositoryURL}
</GithubLink>
</CopyToClipBoard>
}
/>
</RepositoryListItem>
</List>
);
};
private renderContent(url: string): ReactElement<HTMLElement> {
return <CopyToClipBoard text={url}>{this.renderRepositoryText(url)}</CopyToClipBoard>;
}
}
export default Repository;

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Repository /> component should load the component in default state 1`] = `null`;
exports[`<Repository /> component should render the component in default state 1`] = `"<ul class=\\"MuiList-root MuiList-dense MuiList-padding MuiList-subheader\\"><h6 class=\\"MuiTypography-root css-1na337r-StyledText e1wmjxnh0 MuiTypography-subtitle1\\">Repository</h6><div class=\\"MuiButtonBase-root MuiListItem-root css-719o4l-RepositoryListItem e1wmjxnh4 MuiListItem-dense MuiListItem-gutters MuiListItem-button\\" tabindex=\\"0\\" role=\\"button\\" aria-disabled=\\"false\\"><div class=\\"MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault\\"></div><div class=\\"MuiListItemText-root css-1lp4n02-RepositoryListItemText e1wmjxnh5 MuiListItemText-dense\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body2\\"><div class=\\"css-1in239f-ClipBoardCopy eb8w2fo0\\"><span class=\\"css-7gar9h-ClipBoardCopyText eb8w2fo1\\"><a href=\\"git+https://github.com/verdaccio/ui.git\\" target=\\"_blank\\" class=\\"css-6tc7qn-GithubLink e1wmjxnh2\\">git+https://github.com/verdaccio/ui.git</a></span><button class=\\"MuiButtonBase-root MuiIconButton-root css-1fs86cq-CopyIcon eb8w2fo2\\" tabindex=\\"0\\" type=\\"button\\" title=\\"Copy to Clipboard\\"><span class=\\"MuiIconButton-label\\"><svg class=\\"MuiSvgIcon-root\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path d=\\"M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm-1 4l6 6v10c0 1.1-.9 2-2 2H7.99C6.89 23 6 22.1 6 21l.01-14c0-1.1.89-2 1.99-2h7zm-1 7h5.5L14 6.5V12z\\"></path></svg></span><span class=\\"MuiTouchRipple-root\\"></span></button></div></span></div><span class=\\"MuiTouchRipple-root\\"></span></div></ul>"`;

View File

@@ -0,0 +1,40 @@
import styled from '@emotion/styled';
import Github from '../../icons/GitHub';
import { fontWeight } from '../../utils/styles/sizes';
import Text from '../../muiComponents/Text';
import ListItem from '../../muiComponents/ListItem';
import ListItemText from '../../muiComponents/ListItemText';
import Grid from '../../muiComponents/Grid';
import { Theme } from '../../design-tokens/theme';
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
textTransform: 'capitalize',
});
export const GridRepo = styled(Grid)({
alignItems: 'center',
});
export const GithubLink = styled('a')<{ theme?: Theme }>(props => ({
color: props.theme && props.theme.palette.primary.main,
}));
export const GithubLogo = styled(Github)<{ theme?: Theme }>(props => ({
fontSize: 40,
color: props.theme && props.theme.palette.primary.main,
backgroundColor: props.theme && props.theme.palette.greySuperLight,
}));
export const RepositoryListItem = styled(ListItem)({
padding: 0,
':hover': {
backgroundColor: 'transparent',
},
});
export const RepositoryListItemText = styled(ListItemText)({
padding: '0 10px',
margin: 0,
});

View File

@@ -1,147 +1,272 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import '@testing-library/jest-dom/extend-expect';
import { BrowserRouter } from 'react-router-dom';
import { render, fireEvent, waitForElement } from '../../utils/test-react-testing-library';
import api from '../../utils/api';
import { mount, shallow } from '../../utils/test-enzyme';
import Search from './Search';
/* eslint-disable verdaccio/jsx-spread */
const ComponentToBeRendered: React.FC = () => (
<Router>
<Search />
</Router>
);
const SEARCH_FILE_PATH = './Search';
const API_FILE_PATH = '../../utils/calls';
const URL_FILE_PATH = '../../utils/url';
describe('<Search /> component', () => {
// Global mocks
const event = {
stopPropagation: jest.fn(),
};
window.location.assign = jest.fn();
describe('<Search /> component test', () => {
let routerWrapper;
let wrapper;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
jest.spyOn(api, 'request').mockImplementation(() =>
Promise.resolve([
{
name: '@verdaccio/types',
version: '8.4.2',
},
{
name: 'verdaccio',
version: '4.3.5',
},
])
routerWrapper = mount(
<BrowserRouter>
<Search />
</BrowserRouter>
);
});
test('should load the component in default state', () => {
const { container } = render(<ComponentToBeRendered />);
expect(container.firstChild).toMatchSnapshot();
});
test('handleSearch: when user type package name in search component, show suggestions', async () => {
const { getByPlaceholderText, getAllByText } = render(<ComponentToBeRendered />);
const autoCompleteInput = getByPlaceholderText('Search Packages');
fireEvent.focus(autoCompleteInput);
fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } });
expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio');
const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true }));
expect(suggestionsElements).toHaveLength(2);
expect(api.request).toHaveBeenCalledTimes(1);
expect(routerWrapper.html()).toMatchSnapshot();
});
test('onBlur: should cancel all search requests', async () => {
const { getByPlaceholderText, getByRole, getAllByText } = render(<ComponentToBeRendered />);
const Search = require(SEARCH_FILE_PATH).Search;
const autoCompleteInput = getByPlaceholderText('Search Packages');
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
fireEvent.focus(autoCompleteInput);
fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } });
expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio');
wrapper = routerWrapper.find(Search).dive();
const { handleOnBlur, requestList, setState } = wrapper.instance();
const spyCancelAllSearchRequests = jest.spyOn(wrapper.instance(), 'cancelAllSearchRequests');
setState({ search: 'verdaccio' });
const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true }));
expect(suggestionsElements).toHaveLength(2);
expect(api.request).toHaveBeenCalledTimes(1);
const request = {
abort: jest.fn(),
};
// adds a request for AbortController
wrapper.instance().requestList = [request];
fireEvent.blur(autoCompleteInput);
const listBoxElement = await waitForElement(() => getByRole('listbox'));
expect(listBoxElement).toBeEmpty();
handleOnBlur(event);
expect(request.abort).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(wrapper.state('error')).toBeFalsy();
expect(wrapper.state('loaded')).toBeFalsy();
expect(wrapper.state('loading')).toBeFalsy();
expect(spyCancelAllSearchRequests).toHaveBeenCalled();
expect(requestList).toEqual([]);
});
test('handleSearch: cancel all search requests when there is no value in search component with type method', async () => {
const { getByPlaceholderText, getByRole } = render(<ComponentToBeRendered />);
test('handleSearch: when user type package name in search component and set loading to true', () => {
const Search = require(SEARCH_FILE_PATH).Search;
const autoCompleteInput = getByPlaceholderText('Search Packages');
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
fireEvent.focus(autoCompleteInput);
fireEvent.change(autoCompleteInput, { target: { value: ' ', method: 'type' } });
expect(autoCompleteInput).toHaveAttribute('value', '');
const listBoxElement = await waitForElement(() => getByRole('listbox'));
expect(listBoxElement).toBeEmpty();
expect(api.request).toHaveBeenCalledTimes(0);
wrapper = routerWrapper.find(Search).dive();
const { handleSearch } = wrapper.instance();
const newValue = 'verdaccio';
handleSearch(event, { newValue, method: 'type' });
expect(event.stopPropagation).toHaveBeenCalled();
expect(wrapper.state('error')).toBeFalsy();
expect(wrapper.state('loaded')).toBeFalsy();
expect(wrapper.state('loading')).toBeTruthy();
expect(wrapper.state('search')).toEqual(newValue);
});
test('handleSearch: when method is not type method', async () => {
const { getByPlaceholderText, getByRole } = render(<ComponentToBeRendered />);
test('handleSearch: cancel all search requests when there is no value in search component with type method', () => {
const Search = require(SEARCH_FILE_PATH).Search;
const autoCompleteInput = getByPlaceholderText('Search Packages');
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
fireEvent.focus(autoCompleteInput);
fireEvent.change(autoCompleteInput, { target: { value: ' ', method: 'click' } });
expect(autoCompleteInput).toHaveAttribute('value', '');
const listBoxElement = await waitForElement(() => getByRole('listbox'));
expect(listBoxElement).toBeEmpty();
expect(api.request).toHaveBeenCalledTimes(0);
wrapper = routerWrapper.find(Search).dive();
const { handleSearch, requestList } = wrapper.instance();
const spy = jest.spyOn(wrapper.instance(), 'cancelAllSearchRequests');
const newValue = '';
handleSearch(event, { newValue, method: 'type' });
expect(event.stopPropagation).toHaveBeenCalled();
expect(wrapper.state('error')).toBeFalsy();
expect(wrapper.state('loaded')).toBeFalsy();
expect(wrapper.state('loading')).toBeTruthy();
expect(wrapper.state('search')).toEqual(newValue);
expect(spy).toHaveBeenCalled();
expect(requestList).toEqual([]);
});
test('handleSearch: loading is been displayed', async () => {
const { getByPlaceholderText, getByRole, getByText } = render(<ComponentToBeRendered />);
const autoCompleteInput = getByPlaceholderText('Search Packages');
test('handleSearch: when method is not type method', () => {
const Search = require(SEARCH_FILE_PATH).Search;
fireEvent.focus(autoCompleteInput);
fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } });
expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio');
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
const loadingElement = await waitForElement(() => getByText('Loading...'));
expect(loadingElement).toBeTruthy();
wrapper = routerWrapper.find(Search).dive();
const { handleSearch } = wrapper.instance();
const newValue = '';
handleSearch(event, { newValue, method: 'click' });
expect(event.stopPropagation).toHaveBeenCalled();
expect(wrapper.state('error')).toBeFalsy();
expect(wrapper.state('loaded')).toBeFalsy();
expect(wrapper.state('loading')).toBeFalsy();
expect(wrapper.state('search')).toEqual(newValue);
});
test('handlePackagesClearRequested: should clear suggestions', async () => {
const { getByPlaceholderText, getAllByText, getByRole } = render(<ComponentToBeRendered />);
const autoCompleteInput = getByPlaceholderText('Search Packages');
test('handlePackagesClearRequested: should clear suggestions', () => {
const Search = require(SEARCH_FILE_PATH).Search;
fireEvent.focus(autoCompleteInput);
fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } });
expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio');
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true }));
expect(suggestionsElements).toHaveLength(2);
wrapper = routerWrapper.find(Search).dive();
fireEvent.change(autoCompleteInput, { target: { value: ' ' } });
const listBoxElement = await waitForElement(() => getByRole('listbox'));
expect(listBoxElement).toBeEmpty();
const { handlePackagesClearRequested } = wrapper.instance();
expect(api.request).toHaveBeenCalledTimes(2);
handlePackagesClearRequested();
expect(wrapper.state('suggestions')).toEqual([]);
});
test('handleClickSearch: should change the window location on click or return key', async () => {
const { getByPlaceholderText, getAllByText, getByRole } = render(<ComponentToBeRendered />);
const autoCompleteInput = getByPlaceholderText('Search Packages');
describe('<Search /> component: mocks specific tests ', () => {
beforeEach(() => {
jest.resetModules();
jest.doMock('lodash/debounce', () => {
return function debounceMock(fn) {
return fn;
};
});
});
fireEvent.focus(autoCompleteInput);
fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } });
expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio');
test('handleFetchPackages: should load the packages from API', async () => {
const apiResponse = [{ name: 'verdaccio' }, { name: 'verdaccio-htpasswd' }];
const suggestions = [{ name: 'verdaccio' }, { name: 'verdaccio-htpasswd' }];
const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true }));
expect(suggestionsElements).toHaveLength(2);
jest.doMock(API_FILE_PATH, () => ({
callSearch(url: string) {
return Promise.resolve(apiResponse);
},
}));
// click on the second suggestion
fireEvent.click(suggestionsElements[1]);
const listBoxElement = await waitForElement(() => getByRole('listbox'));
// when the page redirects, the list box should be empty again
expect(listBoxElement).toBeEmpty();
const Search = require(SEARCH_FILE_PATH).Search;
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
wrapper = routerWrapper.find(Search).dive();
wrapper.setState({ search: 'verdaccio' });
const { handleFetchPackages } = wrapper.instance();
await handleFetchPackages({ value: 'verdaccio' });
expect(wrapper.state('suggestions')).toEqual(suggestions);
expect(wrapper.state('error')).toBeFalsy();
expect(wrapper.state('loaded')).toBeTruthy();
expect(wrapper.state('loading')).toBeFalsy();
});
test('handleFetchPackages: when browser cancel a request', async () => {
const apiResponse = { name: 'AbortError' };
jest.doMock(API_FILE_PATH, () => ({ callSearch: jest.fn(() => Promise.reject(apiResponse)) }));
const Search = require(SEARCH_FILE_PATH).Search;
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
wrapper = routerWrapper.find(Search).dive();
const { handleFetchPackages, setState } = wrapper.instance();
setState({ search: 'verdaccio' });
await handleFetchPackages({ value: 'verdaccio' });
expect(wrapper.state('error')).toBeFalsy();
expect(wrapper.state('loaded')).toBeFalsy();
expect(wrapper.state('loading')).toBeFalsy();
});
test('handleFetchPackages: when API server failed request', async () => {
const apiResponse = { name: 'BAD_REQUEST' };
jest.doMock(API_FILE_PATH, () => ({
callSearch(url) {
return Promise.reject(apiResponse);
},
}));
const Search = require(SEARCH_FILE_PATH).Search;
const routerWrapper = shallow(
<BrowserRouter>
<Search />
</BrowserRouter>
);
wrapper = routerWrapper.find(Search).dive();
wrapper.setState({ search: 'verdaccio' });
const { handleFetchPackages } = wrapper.instance();
await handleFetchPackages({ value: 'verdaccio' });
expect(wrapper.state('error')).toBeTruthy();
expect(wrapper.state('loaded')).toBeFalsy();
expect(wrapper.state('loading')).toBeFalsy();
});
test('handleClickSearch: should change the window location on click or return key', () => {
const getDetailPageURL = jest.fn(() => 'detail/page/url');
jest.doMock(URL_FILE_PATH, () => ({ getDetailPageURL }));
const suggestionValue = [];
const Search = require(SEARCH_FILE_PATH).Search;
const pushHandler = jest.fn();
const routerWrapper = shallow(
<BrowserRouter>
<Search history={{ push: pushHandler }} />
</BrowserRouter>
);
wrapper = routerWrapper.find(Search).dive();
const { handleClickSearch } = wrapper.instance();
// click
handleClickSearch(event, { suggestionValue, method: 'click' });
expect(event.stopPropagation).toHaveBeenCalled();
expect(pushHandler).toHaveBeenCalledTimes(1);
// return key
handleClickSearch(event, { suggestionValue, method: 'enter' });
expect(event.stopPropagation).toHaveBeenCalled();
expect(pushHandler).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -1,12 +1,32 @@
import React, { useState, FormEvent, useCallback } from 'react';
import React, { KeyboardEvent, Component, ReactElement } from 'react';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { SuggestionSelectedEventData, ChangeEvent } from 'react-autosuggest';
import styled from '@emotion/styled';
import { default as IconSearch } from '@material-ui/icons/Search';
import debounce from 'lodash/debounce';
import { RouteComponentProps, withRouter } from 'react-router';
import { SuggestionSelectedEventData } from 'react-autosuggest';
import InputAdornment from '../../muiComponents/InputAdornment';
import AutoComplete from '../AutoComplete';
import { callSearch } from '../../utils/calls';
import { Theme } from '../../design-tokens/theme';
import SearchAdornment from './SearchAdornment';
export interface State {
search: string;
suggestions: unknown[];
loading: boolean;
loaded: boolean;
error: boolean;
}
export type cancelAllSearchRequests = () => void;
export type handlePackagesClearRequested = () => void;
export type handleSearch = (event: React.FormEvent<HTMLInputElement>, { newValue, method }: ChangeEvent) => void;
export type handleClickSearch = (
event: KeyboardEvent<HTMLInputElement>,
{ suggestionValue, method }: { suggestionValue: object[]; method: string }
) => void;
export type handleFetchPackages = ({ value: string }) => Promise<void>;
export type onBlur = (event: React.FormEvent<HTMLInputElement>) => void;
const CONSTANTS = {
API_DELAY: 300,
@@ -14,142 +34,168 @@ const CONSTANTS = {
ABORT_ERROR: 'AbortError',
};
const Search: React.FC<RouteComponentProps> = ({ history }) => {
const [suggestions, setSuggestions] = useState([]);
const [loaded, setLoaded] = useState(false);
const [search, setSearch] = useState('');
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [requestList, setRequestList] = useState<Array<{ abort: () => void }>>([]);
const StyledInputAdornment = styled(InputAdornment)<{ theme?: Theme }>(props => ({
color: props.theme && props.theme.palette.white,
}));
export class Search extends Component<RouteComponentProps<{}>, State> {
constructor(props: RouteComponentProps<{}>) {
super(props);
this.state = {
search: '',
suggestions: [],
// loading: A boolean value to indicate that request is in pending state.
loading: false,
// loaded: A boolean value to indicate that result has been loaded.
loaded: false,
// error: A boolean value to indicate API error.
error: false,
};
this.requestList = [];
}
public render(): ReactElement<HTMLElement> {
const { suggestions, search, loaded, loading, error } = this.state;
return (
<AutoComplete
onBlur={this.handleOnBlur}
onChange={this.handleSearch}
onCleanSuggestions={this.handlePackagesClearRequested}
onClick={this.handleClickSearch}
onSuggestionsFetch={debounce(this.handleFetchPackages, CONSTANTS.API_DELAY)}
placeholder={CONSTANTS.PLACEHOLDER_TEXT}
startAdornment={this.getAdorment()}
suggestions={suggestions}
suggestionsError={error}
suggestionsLoaded={loaded}
suggestionsLoading={loading}
value={search}
/>
);
}
/**
* Cancel all the requests which are in pending state.
*/
const cancelAllSearchRequests = useCallback(() => {
requestList.forEach(request => request.abort());
setRequestList([]);
}, [requestList, setRequestList]);
/**
* As user focuses out from input, we cancel all the request from requestList
* and set the API state parameters to default boolean values.
*/
const handleOnBlur = useCallback(
(event: FormEvent<HTMLInputElement>) => {
// stops event bubbling
event.stopPropagation();
setLoaded(false);
setLoading(false);
setError(false);
cancelAllSearchRequests();
},
[setLoaded, setLoading, cancelAllSearchRequests, setError]
);
/**
* onChange method for the input element.
*/
const handleSearch = useCallback(
(event: FormEvent<HTMLInputElement>, { newValue, method }) => {
// stops event bubbling
event.stopPropagation();
if (method === 'type') {
const value = newValue.trim();
setLoading(true);
setError(false);
setSearch(value);
setLoaded(false);
/**
* A use case where User keeps adding and removing value in input field,
* so we cancel all the existing requests when input is empty.
*/
if (value.length === 0) {
cancelAllSearchRequests();
}
}
},
[cancelAllSearchRequests]
);
private cancelAllSearchRequests: cancelAllSearchRequests = () => {
this.requestList.forEach(request => request.abort());
this.requestList = [];
};
/**
* Cancel all the request from list and make request list empty.
*/
const handlePackagesClearRequested = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
private handlePackagesClearRequested: handlePackagesClearRequested = () => {
this.setState({
suggestions: [],
});
};
/**
* onChange method for the input element.
*/
private handleSearch: handleSearch = (event, { newValue, method }) => {
// stops event bubbling
event.stopPropagation();
if (method === 'type') {
const value = newValue.trim();
this.setState(
{
search: value,
loading: true,
loaded: false,
error: false,
},
() => {
/**
* A use case where User keeps adding and removing value in input field,
* so we cancel all the existing requests when input is empty.
*/
if (value.length === 0) {
this.cancelAllSearchRequests();
}
}
);
}
};
/**
* When an user select any package by clicking or pressing return key.
*/
const handleClickSearch = useCallback(
(
event: FormEvent<HTMLInputElement>,
{ suggestionValue, method }: SuggestionSelectedEventData<unknown>
): void | undefined => {
// stops event bubbling
event.stopPropagation();
switch (method) {
case 'click':
case 'enter':
setSearch('');
history.push(`/-/web/detail/${suggestionValue}`);
break;
}
},
[history]
);
private handleClickSearch = (
event: React.FormEvent<HTMLInputElement>,
{ suggestionValue, method }: SuggestionSelectedEventData<unknown>
): void | undefined => {
const { history } = this.props;
// stops event bubbling
event.stopPropagation();
switch (method) {
case 'click':
case 'enter':
this.setState({ search: '' });
history.push(`/-/web/detail/${suggestionValue}`);
break;
}
};
/**
* Fetch packages from API.
* For AbortController see: https://developer.mozilla.org/en-US/docs/Web/API/AbortController
*/
const handleFetchPackages = useCallback(
async ({ value }: { value: string }) => {
try {
const controller = new window.AbortController();
const signal = controller.signal;
// Keep track of search requests.
setRequestList([...requestList, controller]);
const suggestions = await callSearch(value, signal);
// @ts-ignore FIXME: Argument of type 'unknown' is not assignable to parameter of type 'SetStateAction<never[]>'
setSuggestions(suggestions);
setLoaded(true);
} catch (error) {
/**
* AbortError is not the API error.
* It means browser has cancelled the API request.
*/
if (error.name === CONSTANTS.ABORT_ERROR) {
setError(false);
setLoaded(false);
} else {
setError(true);
setLoaded(false);
}
} finally {
setLoading(false);
private handleFetchPackages: handleFetchPackages = async ({ value }) => {
try {
const controller = new window.AbortController();
const signal = controller.signal;
// Keep track of search requests.
this.requestList.push(controller);
const suggestions = await callSearch(value, signal);
// @ts-ignore
this.setState({
suggestions,
loaded: true,
});
} catch (error) {
/**
* AbortError is not the API error.
* It means browser has cancelled the API request.
*/
if (error.name === CONSTANTS.ABORT_ERROR) {
this.setState({ error: false, loaded: false });
} else {
this.setState({ error: true, loaded: false });
}
},
[requestList, setRequestList, setSuggestions, setLoaded, setError, setLoading]
);
} finally {
this.setState({ loading: false });
}
};
return (
<AutoComplete
onBlur={handleOnBlur}
onChange={handleSearch}
onCleanSuggestions={handlePackagesClearRequested}
onClick={handleClickSearch}
onSuggestionsFetch={debounce(handleFetchPackages, CONSTANTS.API_DELAY)}
placeholder={CONSTANTS.PLACEHOLDER_TEXT}
startAdornment={<SearchAdornment />}
suggestions={suggestions}
suggestionsError={error}
suggestionsLoaded={loaded}
suggestionsLoading={loading}
value={search}
/>
);
};
private requestList: AbortController[];
public getAdorment(): JSX.Element {
return (
<StyledInputAdornment position={'start'}>
<IconSearch />
</StyledInputAdornment>
);
}
/**
* As user focuses out from input, we cancel all the request from requestList
* and set the API state parameters to default boolean values.
*/
private handleOnBlur: onBlur = event => {
// stops event bubbling
event.stopPropagation();
this.setState(
{
loaded: false,
loading: false,
error: false,
},
() => this.cancelAllSearchRequests()
);
};
}
export default withRouter(Search);

View File

@@ -1,18 +0,0 @@
import React from 'react';
import Search from '@material-ui/icons/Search';
import styled from '@emotion/styled';
import InputAdornment from '../../muiComponents/InputAdornment';
import { Theme } from '../../design-tokens/theme';
const StyledInputAdornment = styled(InputAdornment)<{ theme?: Theme }>(props => ({
color: props.theme && props.theme.palette.white,
}));
const SearchAdornment: React.FC = () => (
<StyledInputAdornment position={'start'}>
<Search />
</StyledInputAdornment>
);
export default SearchAdornment;

View File

@@ -1,87 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Search /> component should load the component in default state 1`] = `
.emotion-6 {
width: 100%;
height: 32px;
position: relative;
z-index: 1;
}
.emotion-2 .MuiInputBase-root:before {
content: '';
border: none;
}
.emotion-2 .MuiInputBase-root:after {
border-color: #fff;
}
.emotion-2 .MuiInputBase-root:hover:before {
content: none;
}
.emotion-2 .MuiInputBase-input {
color: #fff;
}
.emotion-0 {
color: #fff;
}
.emotion-4 {
max-height: 500px;
overflow-y: auto;
}
<div
class="emotion-6 emotion-7"
>
<div
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="react-autowhatever-1"
class="react-autosuggest__container"
role="combobox"
>
<div
aria-autocomplete="list"
aria-controls="react-autowhatever-1"
class="MuiFormControl-root MuiTextField-root react-autosuggest__input emotion-2 emotion-3 MuiFormControl-fullWidth"
>
<div
class="MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-fullWidth MuiInput-fullWidth MuiInputBase-formControl MuiInput-formControl MuiInputBase-adornedStart"
>
<div
class="MuiInputAdornment-root emotion-0 emotion-1 MuiInputAdornment-positionStart"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
</div>
<input
aria-invalid="false"
autocomplete="off"
class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedStart"
placeholder="Search Packages"
type="text"
value=""
/>
</div>
</div>
<div
class="MuiPaper-root MuiPaper-elevation1 react-autosuggest__suggestions-container emotion-4 emotion-5"
id="react-autowhatever-1"
role="listbox"
/>
</div>
</div>
`;
exports[`<Search /> component test should load the component in default state 1`] = `"<div class=\\"css-pnwf4z-Wrapper e1rflf270\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-owns=\\"react-autowhatever-1\\" aria-expanded=\\"false\\" class=\\"react-autosuggest__container\\"><div class=\\"MuiFormControl-root MuiTextField-root react-autosuggest__input css-ae5nkp-StyledTextField e1rflf271 MuiFormControl-fullWidth\\" aria-autocomplete=\\"list\\" aria-controls=\\"react-autowhatever-1\\"><div class=\\"MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-fullWidth MuiInput-fullWidth MuiInputBase-formControl MuiInput-formControl MuiInputBase-adornedStart\\"><div class=\\"MuiInputAdornment-root css-1wub48n-StyledInputAdornment e1n3ivvz0 MuiInputAdornment-positionStart\\"><svg class=\\"MuiSvgIcon-root\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path d=\\"M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z\\"></path></svg></div><input aria-invalid=\\"false\\" autocomplete=\\"off\\" placeholder=\\"Search Packages\\" type=\\"text\\" class=\\"MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedStart\\" value=\\"\\"></div></div><div class=\\"MuiPaper-root MuiPaper-elevation1 react-autosuggest__suggestions-container css-kc7cda-SuggestionContainer e1rflf272\\" id=\\"react-autowhatever-1\\" role=\\"listbox\\"></div></div></div>"`;

View File

@@ -31,7 +31,7 @@ const UpLinks: React.FC = () => {
<ListItem key={name}>
<ListItemText>{name}</ListItemText>
<Spacer />
<ListItemText>{formatDateDistance(uplinks[name].fetched)}</ListItemText>
<ListItemText>{`${formatDateDistance(uplinks[name].fetched)} ago`}</ListItemText>
</ListItem>
))}
</List>

View File

@@ -2,4 +2,4 @@
exports[`<UpLinks /> component should render the component when there is no uplink 1`] = `"<h6 class=\\"MuiTypography-root MuiTypography-subtitle1 MuiTypography-gutterBottom\\">verdaccio has no uplinks.</h6>"`;
exports[`<UpLinks /> component should render the component with uplinks 1`] = `"<h6 class=\\"MuiTypography-root css-5wp24z-StyledText e14i1sy10 MuiTypography-subtitle1\\">Uplinks</h6><ul class=\\"MuiList-root MuiList-padding\\"><li class=\\"MuiListItem-root MuiListItem-gutters\\"><div class=\\"MuiListItemText-root css-1pxn9ma-ListItemText e14i1sy12\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">npmjs</span></div><div class=\\"css-t1rp47-Spacer e14i1sy11\\"></div><div class=\\"MuiListItemText-root css-1pxn9ma-ListItemText e14i1sy12\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">a year ago</span></div></li></ul>"`;
exports[`<UpLinks /> component should render the component with uplinks 1`] = `"<h6 class=\\"MuiTypography-root css-5wp24z-StyledText e14i1sy10 MuiTypography-subtitle1\\">Uplinks</h6><ul class=\\"MuiList-root MuiList-padding\\"><li class=\\"MuiListItem-root MuiListItem-gutters\\"><div class=\\"MuiListItemText-root css-1pxn9ma-ListItemText e14i1sy12\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">npmjs</span></div><div class=\\"css-t1rp47-Spacer e14i1sy11\\"></div><div class=\\"MuiListItemText-root css-1pxn9ma-ListItemText e14i1sy12\\"><span class=\\"MuiTypography-root MuiListItemText-primary MuiTypography-body1\\">over 1 year ago</span></div></li></ul>"`;

View File

@@ -2,11 +2,11 @@ import styled from '@emotion/styled';
import Text from '../../muiComponents/Text';
import { default as MuiListItemText } from '../../muiComponents/ListItemText';
import { Theme } from '../../design-tokens/theme';
import { fontWeight } from '../../utils/styles/sizes';
export const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
}));
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
});
export const Spacer = styled('div')({
flex: '1 1 auto',

View File

@@ -25,7 +25,7 @@ const VersionsHistoryList: React.FC<Props> = ({ versions, packageName, time }) =
<ListItemText>{version}</ListItemText>
</StyledLink>
<Spacer />
<ListItemText>{time[version] ? formatDateDistance(time[version]) : NOT_AVAILABLE}</ListItemText>
<ListItemText>{time[version] ? `${formatDateDistance(time[version])} ago` : NOT_AVAILABLE}</ListItemText>
</ListItem>
))}
</List>

View File

@@ -1,13 +1,13 @@
import styled from '@emotion/styled';
import { fontWeight } from '../../utils/styles/sizes';
import Text from '../../muiComponents/Text';
import { default as MuiListItemText } from '../../muiComponents/ListItemText';
import { Theme } from '../../design-tokens/theme';
import Link from '../Link';
export const StyledText = styled(Text)<{ theme?: Theme }>(props => ({
fontWeight: props.theme && props.theme.fontWeight.bold,
}));
export const StyledText = styled(Text)({
fontWeight: fontWeight.bold,
});
export const Spacer = styled('div')({
flex: '1 1 auto',

View File

@@ -1,16 +1,17 @@
import { makeStyles } from '@material-ui/styles';
import React from 'react';
import { Theme } from './theme';
import { fontWeight } from '../utils/styles/sizes';
import { breakpoints } from '../utils/styles/media';
const resetStyles = makeStyles(({ theme }: { theme?: Theme }) => ({
const resetStyles = makeStyles(() => ({
'@global': {
// eslint-disable-next-line max-len
'html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video': {
fontFamily: '"Roboto", Helvetica Neue, Arial, sans-serif',
},
strong: {
fontWeight: theme && theme.fontWeight.semiBold,
fontWeight: fontWeight.semiBold,
},
'html, body, #root': {
height: '100%',
@@ -24,8 +25,8 @@ const resetStyles = makeStyles(({ theme }: { theme?: Theme }) => ({
padding: 15,
flex: 1,
[`@media screen and (min-width: ${theme && theme.breakPoints.container}px)`]: {
maxWidth: theme && theme.breakPoints.container,
[`@media screen and (min-width: ${breakpoints.container}px)`]: {
maxWidth: breakpoints.container,
width: '100%',
marginLeft: 'auto',
marginRight: 'auto',

View File

@@ -28,44 +28,6 @@ const colors = {
export type Colors = keyof typeof colors;
const fontSize = {
xxl: 26,
xl: 24,
lg: 21,
md: 18,
default: 16,
sm: 14,
};
export type FontSize = keyof typeof fontSize;
const fontWeight = {
light: 300,
regular: 400,
semiBold: 500,
bold: 700,
};
export type FontWeight = keyof typeof fontWeight;
export const breakPoints = {
small: 576,
medium: 768,
large: 1024,
container: 1240,
xlarge: 1275,
};
export type BreakPoints = typeof breakPoints;
const customizedTheme = {
fontSize,
fontWeight,
breakPoints,
};
type CustomizedTheme = typeof customizedTheme;
export const theme = createMuiTheme({
typography: {
fontFamily: 'inherit',
@@ -76,18 +38,10 @@ export const theme = createMuiTheme({
secondary: { main: colors.secondary },
error: { main: colors.red },
},
...customizedTheme,
});
export type Theme = typeof theme;
declare module '@material-ui/core/styles/createMuiTheme' {
/* eslint-disable-next-line @typescript-eslint/no-empty-interface */
interface Theme extends CustomizedTheme {}
/* eslint-disable-next-line @typescript-eslint/no-empty-interface */
interface ThemeOptions extends CustomizedTheme {}
}
declare module '@material-ui/core/styles/createPalette' {
interface CustomPalette {
black: string;

View File

@@ -1,6 +1,7 @@
{
"rules": {
"verdaccio/jsx-spread": 0,
"react/display-name": 0,
"react/jsx-sort-props": 0
}
}

View File

@@ -23,6 +23,7 @@ const detailContextValue = {
};
describe('test Version page', () => {
/* eslint-disable react/jsx-max-depth */
test('should render the version page', async () => {
const { getByTestId, getByText } = render(
<MemoryRouter>

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line jest/no-mocks-import
import {
generateTokenWithTimeRange,
generateTokenWithExpirationAsString,
@@ -12,7 +11,6 @@ import { isTokenExpire, makeLogin } from './login';
console.error = jest.fn();
jest.mock('./api', () => ({
// eslint-disable-next-line jest/no-mocks-import
request: require('../../jest/unit/components/__mocks__/api').default.request,
}));
@@ -42,8 +40,11 @@ describe('isTokenExpire', (): void => {
test('isTokenExpire - token is not a valid json token', (): void => {
const token = generateInvalidToken();
const errorToken = new SyntaxError('Unexpected token i in JSON at position 0');
const result = ['Invalid token:', errorToken, 'xxxxxx.aW52YWxpZHRva2Vu.xxxxxx'];
const result = [
'Invalid token:',
new SyntaxError('Unexpected token i in JSON at position 0'),
'xxxxxx.aW52YWxpZHRva2Vu.xxxxxx',
];
expect(isTokenExpire(token)).toBeTruthy();
expect(console.error).toHaveBeenCalledWith(...result);
});
@@ -59,6 +60,7 @@ describe('makeLogin', (): void => {
const result = {
error: {
description: "Username or password can't be empty!",
title: 'Unable to login',
type: 'error',
},
};
@@ -76,7 +78,8 @@ describe('makeLogin', (): void => {
test('makeLogin - login should failed with 401', async () => {
const result = {
error: {
description: 'Unable to sign in',
description: 'bad username/password, access denied',
title: 'Unable to login',
type: 'error',
},
};
@@ -89,6 +92,7 @@ describe('makeLogin', (): void => {
test('makeLogin - login should failed with when no data is sent', async () => {
const result = {
error: {
title: 'Unable to login',
type: 'error',
description: "Username or password can't be empty!",
},

View File

@@ -47,6 +47,7 @@ export interface LoginBody {
}
export interface LoginError {
title: string;
type: string;
description: string;
}
@@ -55,6 +56,7 @@ export async function makeLogin(username?: string, password?: string): Promise<L
// checks isEmpty
if (isEmpty(username) || isEmpty(password)) {
const error = {
title: 'Unable to login',
type: 'error',
description: "Username or password can't be empty!",
};
@@ -75,10 +77,10 @@ export async function makeLogin(username?: string, password?: string): Promise<L
};
return result;
} catch (e) {
console.error('login error', e.message);
const error = {
title: 'Unable to login',
type: 'error',
description: 'Unable to sign in',
description: e.error,
};
return { error };
}

View File

@@ -71,7 +71,7 @@ describe('formatDateDistance', (): void => {
const date2 = dateTwoMonthsAgo();
// FIXME: we need to review this expect, fails every x time.
// expect(formatDateDistance(date1)).toEqual('about 2 months');
expect(formatDateDistance(date2)).toEqual('2 months ago');
expect(formatDateDistance(date2)).toEqual('2 months');
});
});

View File

@@ -2,14 +2,12 @@ import { isObject } from 'util';
import { UpLinks } from '@verdaccio/types';
import isString from 'lodash/isString';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import format from 'date-fns/format';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import { Time } from '../../types/packageMeta';
export const TIMEFORMAT = 'DD.MM.YYYY, HH:mm:ss';
dayjs.extend(relativeTime);
export const TIMEFORMAT = 'dd.MM.yyyy, HH:mm:ss';
/**
* Formats license field for webui.
@@ -54,11 +52,11 @@ export function formatRepository(repository: any): string | null {
}
export function formatDate(lastUpdate: string | number): string {
return dayjs(new Date(lastUpdate)).format(TIMEFORMAT);
return format(new Date(lastUpdate), TIMEFORMAT);
}
export function formatDateDistance(lastUpdate: Date | string | number): string {
return dayjs(new Date(lastUpdate)).fromNow();
return formatDistanceToNow(new Date(lastUpdate));
}
/**

38
src/utils/styles/media.ts Normal file
View File

@@ -0,0 +1,38 @@
import { css, Interpolation } from 'emotion';
export const breakpoints = {
small: 576,
medium: 768,
large: 1024,
container: 1240,
xlarge: 1275,
};
type Breakpoints = typeof breakpoints;
type Sizes = keyof Breakpoints;
type MediaQuery<T> = {
[P in keyof T]: (args: Interpolation, key?: P) => string;
};
function constructMQ(breakpoint: Sizes, args: Interpolation): string {
const label = breakpoints[breakpoint];
const prefix = typeof label === 'string' ? '' : 'min-width:';
const suffix = typeof label === 'string' ? '' : 'px';
return css`
@media (${prefix + breakpoints[breakpoint] + suffix}) {
${args};
}
`;
}
const mq: MediaQuery<Breakpoints> = {
small: (args, b = 'small') => constructMQ(b, args),
large: (args, b = 'large') => constructMQ(b, args),
container: (args, b = 'container') => constructMQ(b, args),
medium: (args, b = 'medium') => constructMQ(b, args),
xlarge: (args, b = 'xlarge') => constructMQ(b, args),
};
export default mq;

View File

@@ -0,0 +1,40 @@
/**
* CSS to represent truncated text with an ellipsis.
*/
export function ellipsis(width: string | number): {} {
return {
display: 'inline-block',
maxWidth: width,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
wordWrap: 'normal',
};
}
/**
* Shorthand that accepts up to four values, including null to skip a value, and maps them to their respective directions.
*/
interface SpacingShortHand<type> {
top?: type;
right?: type;
bottom?: type;
left?: type;
}
const positionMap = ['Top', 'Right', 'Bottom', 'Left'];
export function spacing(property: 'padding' | 'margin', ...values: SpacingShortHand<number | string>[]): {} {
const [firstValue = 0, secondValue = 0, thirdValue = 0, fourthValue = 0] = values;
const valuesWithDefaults = [firstValue, secondValue, thirdValue, fourthValue];
let styles = {};
for (let i = 0; i < valuesWithDefaults.length; i += 1) {
if (valuesWithDefaults[i] || valuesWithDefaults[i] === 0) {
styles = {
...styles,
[`${property}${positionMap[i]}`]: valuesWithDefaults[i],
};
}
}
return styles;
}

22
src/utils/styles/sizes.ts Normal file
View File

@@ -0,0 +1,22 @@
export const fontSize = {
xxl: '26px',
xl: '24px',
lg: '21px',
md: '18px',
base: '16px',
sm: '14px',
};
export const lineHeight = {
xl: '30px',
sm: '18px',
xs: '2',
xxs: '1.5',
};
export const fontWeight = {
light: 300,
regular: 400,
semiBold: 500,
bold: 700,
};

View File

@@ -0,0 +1,7 @@
// Spacings
// -------------------------
export const spacings = {
lg: '30px',
sm: '16px',
};

View File

@@ -2,6 +2,8 @@ import { mount, shallow } from 'enzyme';
import ThemeProvider from '../design-tokens/ThemeProvider';
/* eslint-disable @typescript-eslint/explicit-function-return-type */
const shallowWithTheme = (element: React.ReactElement<any>, ...props): any =>
shallow(element, {
wrappingComponent: ThemeProvider,

View File

@@ -3,6 +3,7 @@ import React from 'react';
import ThemeProvider from '../design-tokens/ThemeProvider';
/* eslint-disable @typescript-eslint/explicit-function-return-type */
const customRender = (node: React.ReactElement<any>, ...options: Array<any>) => {
return render(<ThemeProvider>{node}</ThemeProvider>, ...options);
};

View File

@@ -1,6 +1,7 @@
{
"rules": {
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-member-accessibility": 0,
"no-console": 0,

View File

@@ -30,16 +30,16 @@ describe('/ (Verdaccio Page)', () => {
await clickElement('button[data-testid="header--button-login"]');
await page.waitFor(500);
// we fill the sign in form
const signInDialog = await page.$('#login--dialog');
const userInput = await signInDialog.$('#login--dialog-username');
const signInDialog = await page.$('#login--form-container');
const userInput = await signInDialog.$('#login--form-username');
expect(userInput).not.toBeNull();
const passInput = await signInDialog.$('#login--dialog-password');
const passInput = await signInDialog.$('#login--form-password');
expect(passInput).not.toBeNull();
await userInput.type('test', { delay: 100 });
await passInput.type('test', { delay: 100 });
await passInput.dispose();
// click on log in
const loginButton = await page.$('#login--dialog-button-submit');
const loginButton = await page.$('#login--form-submit');
expect(loginButton).toBeDefined();
await loginButton.focus();
await loginButton.click({ delay: 100 });
@@ -89,7 +89,8 @@ describe('/ (Verdaccio Page)', () => {
const signInButton = await page.$('button[data-testid="header--button-login"]');
await signInButton.click();
await page.waitFor(1000);
const signInDialog = await page.$('#login--dialog');
const signInDialog = await page.$('#login--form-container');
expect(signInDialog).not.toBeNull();
});
//

View File

@@ -17,7 +17,7 @@
"src",
"types/*.d.ts",
"scripts/lib",
"node_modules/config",
"node_modules/config"
],
"exclude": [
"node_modules"

1
types/jest-dom.d.ts vendored
View File

@@ -1 +0,0 @@
import '@testing-library/jest-dom/extend-expect';

View File

@@ -8,7 +8,6 @@ export interface PackageMetaInterface {
dist: {
fileCount: number;
unpackedSize: number;
tarball?: string;
};
engines?: {
node?: string;
@@ -16,14 +15,6 @@ export interface PackageMetaInterface {
};
license?: Partial<LicenseInterface> | string;
version: string;
homepage?: string;
bugs?: {
url: string;
};
repository?: {
type?: string;
url?: string;
};
};
_uplinks: {};
}

1921
yarn.lock

File diff suppressed because it is too large Load Diff