Compare commits

...

99 Commits

Author SHA1 Message Date
Alejandro Celaya
2ba86767fe Merge pull request #124 from acelaya/feature/paginated-charts
Feature/paginated charts
2019-03-16 09:11:01 +01:00
Alejandro Celaya
391424d8a1 Ensured bar charts are regenerated when their height changes 2019-03-16 09:02:40 +01:00
Alejandro Celaya
e0db6d5a57 Improved SortableBarGraph test 2019-03-10 17:55:02 +01:00
Alejandro Celaya
87dc24e8a2 Fixed and improved OpenMapModalBtn and ShortUrlVisit components tests 2019-03-10 13:05:20 +01:00
Alejandro Celaya
5233f5a07b Updated OpenMapModalBtn so that it allows showing only active cities 2019-03-10 12:09:54 +01:00
Alejandro Celaya
478ee59bb0 Updated cities chart so that map shows locations from current page when result set is paginated 2019-03-10 10:56:36 +01:00
Alejandro Celaya
b6f6b1ae9d Enabled stickiness on footer 2019-03-10 10:08:42 +01:00
Alejandro Celaya
1ad4290487 Applied some naming improvements 2019-03-10 09:54:40 +01:00
Alejandro Celaya
61480abd2e Updated charts to allow optional pagination 2019-03-10 08:28:14 +01:00
Alejandro Celaya
c094a27c97 Created PaginationDropdown component 2019-03-09 13:20:43 +01:00
Alejandro Celaya
83704ca4b5 Created rangeOf helper function which does a range + map 2019-03-09 12:19:33 +01:00
Alejandro Celaya
60576388c5 Merge pull request #122 from acelaya/feature/cancel-visits-load
Feature/cancel visits load
2019-03-08 19:56:45 +01:00
Alejandro Celaya
9f172c308c Ensured travis makes use of a working node version for builds 2019-03-08 19:50:47 +01:00
Alejandro Celaya
d7312d26f7 Added missing test for new action creator 2019-03-08 19:45:35 +01:00
Alejandro Celaya
4e6ef6ac53 Removed empty line 2019-03-08 19:43:27 +01:00
Alejandro Celaya
de563f9ebf Updated changelog 2019-03-08 19:42:07 +01:00
Alejandro Celaya
3982d77775 Ensured visits loading is cancelled when the visits page is unmounted 2019-03-08 19:40:43 +01:00
Alejandro Celaya
24bbbf6cb1 Merge pull request #121 from acelaya/feature/fix-empty-locations
Feature/fix empty locations
2019-03-05 14:33:33 +01:00
Alejandro Celaya
9ddd5de008 Updated changelog 2019-03-05 14:12:11 +01:00
Alejandro Celaya
87a4598391 Ensured maps modal btn is not rendered when the number of located cities is 0 2019-03-05 14:09:08 +01:00
Alejandro Celaya
701c143149 Updated ErrorHandler so that it logs errors in production 2019-03-05 14:04:52 +01:00
Alejandro Celaya
43097b93e5 Fixed docker build badge 2019-03-05 12:11:18 +01:00
Alejandro Celaya
e303a80683 Updated bootstrap to solve security issue 2019-03-04 21:05:30 +01:00
Alejandro Celaya
5defc20e9f Merge pull request #117 from acelaya/feature/error-handler
Feature/error handler
2019-03-04 20:55:48 +01:00
Alejandro Celaya
d75eff62e3 Updated changelog 2019-03-04 20:50:05 +01:00
Alejandro Celaya
ad9f0c00d0 Created ErrorHandler test 2019-03-04 20:49:18 +01:00
Alejandro Celaya
cd908fa358 Created ErrorHandler component 2019-03-04 20:41:02 +01:00
Alejandro Celaya
2bf79dbc80 Merge pull request #114 from acelaya/feature/lat-lang-error
Fixed crash when trying to load a map with just one location
2019-03-04 20:35:10 +01:00
Alejandro Celaya
4c729a405d Fixed crash when trying to load a map with just one location 2019-03-04 20:24:28 +01:00
Alejandro Celaya
28c9f9ac96 Merge pull request #112 from acelaya/feature/many-visits
Improved performance while calculating status
2019-03-04 19:37:00 +01:00
Alejandro Celaya
2820caf955 Updated changelog 2019-03-04 19:29:56 +01:00
Alejandro Celaya
ba5ea7407b Used native javascript reduce instead of ramda reduce 2019-03-04 19:28:24 +01:00
Alejandro Celaya
1bc406b0d9 Ensured requests when loading visits are made in parallel for big dataset 2019-03-04 19:21:46 +01:00
Alejandro Celaya
7e27ceb885 Ensured same timestamp is used when generating memoization ID after mounting the component 2019-03-04 18:19:50 +01:00
Alejandro Celaya
252edaa2ca Improved performance while calculating status by doing one iteration only and memoizing the result when possible 2019-03-04 18:14:45 +01:00
Alejandro Celaya
9a6fad4db5 Merge pull request #110 from acelaya/feature/swipe-map
Prevented side menu to be swipeable while a modal window is displayed
2019-03-03 12:11:28 +01:00
Alejandro Celaya
127bcc14eb Prevented side menu to be swipeable while a modal window is displayed 2019-03-03 12:05:29 +01:00
Alejandro Celaya
220d634f80 Merge pull request #109 from acelaya/feature/map-center
Fixed initial zoom and center on maps
2019-03-03 11:54:44 +01:00
Alejandro Celaya
6291af2865 Fixed initial zoom and center on maps 2019-03-03 11:47:19 +01:00
Alejandro Celaya
e9e808d339 Merge pull request #108 from acelaya/feature/not-found-page
Feature/not found page
2019-03-03 11:24:56 +01:00
Alejandro Celaya
780e4a6e9e Replaced component by render on route rendering not found component with custom props 2019-03-03 11:18:58 +01:00
Alejandro Celaya
c4bc2f24d6 Used not-found component for menu layout inner router 2019-03-03 11:15:34 +01:00
Alejandro Celaya
d23ddd0e0b Created NotFound component 2019-03-03 11:02:29 +01:00
Alejandro Celaya
4f0ee79409 Added missing changelogs 2019-03-03 10:33:37 +01:00
Alejandro Celaya
7b07445c5d Merge pull request #107 from acelaya/feature/improve-docker-image
Feature/improve docker image
2019-03-03 10:28:30 +01:00
Alejandro Celaya
dcbf4bfef8 Added missing docker dependency in travis config 2019-03-03 10:22:26 +01:00
Alejandro Celaya
6ec870bb08 Updated travis so that it tries to build the docker image, whcih in turn builds the project 2019-03-03 10:18:23 +01:00
Alejandro Celaya
2c6dbb42c1 Simplified Dockerfile using multi-stage build 2019-03-03 10:12:59 +01:00
Alejandro Celaya
98725dce04 Added missing array-related dependencies 2019-03-03 10:01:10 +01:00
Alejandro Celaya
2d3363153a Merge pull request #100 from acelaya/feature/tests
Feature/tests
2019-01-13 23:38:01 +01:00
Alejandro Celaya
9318c1c6fb Updated changelog with v2.0.0 2019-01-13 23:32:23 +01:00
Alejandro Celaya
11d49fb70f Created DeleteTagConfirmModal test 2019-01-13 23:31:10 +01:00
Alejandro Celaya
056286636d Created ScrollToTop test 2019-01-13 23:03:31 +01:00
Alejandro Celaya
d020ed0b13 Created ShortUrlsRowMenu test 2019-01-13 13:08:47 +01:00
Alejandro Celaya
30b4cb4068 Created ShortUrlsRow test 2019-01-13 09:49:02 +01:00
Alejandro Celaya
1aa1d29d97 Removed direct calls between actions without DI 2019-01-12 23:59:03 +01:00
Alejandro Celaya
4f8c7afc76 Created SortableBarGraph test 2019-01-12 23:47:41 +01:00
Alejandro Celaya
c2ee688176 Merge pull request #99 from acelaya/feature/visits-pagination
Feature/visits pagination
2019-01-10 20:17:28 +01:00
Alejandro Celaya
f58b815ef8 Fixed typo 2019-01-10 20:11:32 +01:00
Alejandro Celaya
daaf91b836 Updated changelog 2019-01-10 20:06:57 +01:00
Alejandro Celaya
dee5994b1e Fixed tests 2019-01-10 20:05:02 +01:00
Alejandro Celaya
0c1f533747 Updated short URL visits loading so that it loads visits in several requests 2019-01-10 19:50:25 +01:00
Alejandro Celaya
58d3a59e58 Merge pull request #98 from acelaya/feature/clean-api-client
Feature/clean api client
2019-01-10 19:26:28 +01:00
Alejandro Celaya
811008ee1c Updated changelog 2019-01-10 19:20:09 +01:00
Alejandro Celaya
23af0de34a Simplified ShlinkApiCLient by using the new simplified authentication approach 2019-01-10 19:17:15 +01:00
Alejandro Celaya
b96ea89f90 Merge pull request #92 from acelaya/feature/map
Created component to show a map on a modal window
2019-01-09 20:39:59 +01:00
Alejandro Celaya
77a624a889 Removed not needed call to function 2019-01-09 20:34:35 +01:00
Alejandro Celaya
40892f3e91 Updated changelog 2019-01-09 20:31:52 +01:00
Alejandro Celaya
b12dac1e35 Improved map modal title 2019-01-09 20:30:59 +01:00
Alejandro Celaya
150dcd2d5d Created tests for new map-related components 2019-01-09 20:11:22 +01:00
Alejandro Celaya
c599d2837b Improved VisitsParser test 2019-01-09 07:59:56 +01:00
Alejandro Celaya
bb6fb6b9ea Created utils test 2019-01-08 21:19:38 +01:00
Alejandro Celaya
8b8be2d7ca Updated stateFlagTimeout to get the setTimeout function injected as a dependency 2019-01-08 20:49:47 +01:00
Alejandro Celaya
00d386f19f Skipped locations from unknown cities when processing cities stats for map 2019-01-07 21:11:09 +01:00
Alejandro Celaya
4870801f8f Implemented map to show visits from every city 2019-01-07 21:00:28 +01:00
Alejandro Celaya
78745366c2 Moved button to open map to separated component 2019-01-07 19:43:25 +01:00
Alejandro Celaya
2be771cbcc Improved styles 2019-01-07 13:45:16 +01:00
Alejandro Celaya
6abc0e7d02 Created component to show a map on a modal window 2019-01-07 13:35:25 +01:00
Alejandro Celaya
95220b913a Merge pull request #91 from acelaya/feature/cities-graph
Feature/cities graph
2019-01-07 12:02:11 +01:00
Alejandro Celaya
a2c1f5ce7d Improved function name 2019-01-07 11:56:54 +01:00
Alejandro Celaya
5208a266e4 Updated changelog 2019-01-07 11:54:32 +01:00
Alejandro Celaya
dc9c1712ff Added cities stats graphic on short url visits page 2019-01-07 11:53:14 +01:00
Alejandro Celaya
f9360683e9 Added docker build status badge 2019-01-07 10:37:24 +01:00
Alejandro Celaya
456f50620b Updated base docker image used during development 2019-01-07 09:50:14 +01:00
Alejandro Celaya
d76008582f Merge pull request #90 from acelaya/feature/create-react-app-2
Feature/create react app 2
2019-01-07 09:48:19 +01:00
Alejandro Celaya
6e9cbb9b28 Updated docker base image and removed deprecated MAINTAINER instruction 2019-01-07 09:41:54 +01:00
Alejandro Celaya
b5f799528d Updated changelog 2019-01-07 09:27:18 +01:00
Alejandro Celaya
f410657133 Updated build script 2019-01-07 09:24:46 +01:00
Alejandro Celaya
b1e0124aff Fixed fontawesome import 2019-01-07 09:06:29 +01:00
Alejandro Celaya
b3e3bbdd9b Slightly fixed dependencies 2019-01-06 10:46:19 +01:00
Alejandro Celaya
fac3edaea7 Updated coding styles and test configs 2019-01-05 23:16:13 +01:00
Alejandro Celaya
08488e9bad Imported config files and start script from new create react app 2019-01-05 22:54:54 +01:00
Alejandro Celaya
835d54e90c Updated dependencies and source code 2019-01-05 22:25:54 +01:00
Alejandro Celaya
10c3abd29a Happy 2019! 2019-01-05 08:42:34 +01:00
Alejandro Celaya
906e9ddb5c Updated yarn.lock 2019-01-01 12:33:53 +01:00
Alejandro Celaya
c165c8e301 Merge pull request #88 from acelaya/feature/update-dependencies
Updated linting dependencies
2018-12-31 18:31:28 +01:00
Alejandro Celaya
7d63e05900 Updated changelog 2018-12-31 18:27:16 +01:00
Alejandro Celaya
03f409a803 Updated linting dependencies 2018-12-31 18:23:47 +01:00
Alejandro Celaya
f427234373 Move out of Travis container-based infrastructure 2018-12-29 09:38:23 +01:00
107 changed files with 7463 additions and 4331 deletions

View File

@@ -1,4 +1,5 @@
./build
./coverage
./dist
./node_modules
./test

View File

@@ -26,6 +26,7 @@
"no-console": "warn",
"template-curly-spacing": ["error", "never"],
"no-warning-comments": "off",
"no-magic-numbers": "off",
"no-undefined": "off",
"indent": ["error", 2, {
"SwitchCase": 1

4
.gitignore vendored
View File

@@ -1,8 +1,5 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
package-lock.json
# testing
/coverage
@@ -11,7 +8,6 @@ package-lock.json
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local

View File

@@ -1,22 +1,23 @@
language: node_js
sudo: false
node_js:
- "stable"
- "10.15.3"
cache:
yarn: true
directories:
- node_modules
services:
- docker
install:
- yarn install
script:
- yarn lint
- yarn test:ci
- if [[ -z $TRAVIS_TAG ]]; then yarn build ; fi
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi # Test docker image build only when no tag is present
after_success:
- yarn ocular coverage/clover.xml

View File

@@ -4,6 +4,109 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.0.3 - 2019-03-16
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#120](https://github.com/shlinkio/shlink-web-client/issues/120) Fixed crash when visits page is loaded and there are no visits with known cities.
* [#113](https://github.com/shlinkio/shlink-web-client/issues/113) Ensured visits loading is cancelled when the visits page is unmounted. Requests on flight will still finish.
* [#118](https://github.com/shlinkio/shlink-web-client/issues/118) Fixed chart crashing when trying to render lots of bars by adding pagination.
## 2.0.2 - 2019-03-04
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#103](https://github.com/shlinkio/shlink-web-client/issues/103) Fixed visits page getting freezed when loading large amounts of visits.
* [#111](https://github.com/shlinkio/shlink-web-client/issues/111) Fixed crash when trying to load a map modal with only one location.
* [#115](https://github.com/shlinkio/shlink-web-client/issues/115) Created `ErrorHandler` component which will prevent crashes in app to make it unusable.
## 2.0.1 - 2019-03-03
#### Added
* *Nothing*
#### Changed
* [#106](https://github.com/shlinkio/shlink-web-client/issues/106) Reduced size of docker image by using a multi-stage build Dockerfile.
* [#95](https://github.com/shlinkio/shlink-web-client/issues/95) Tested docker image build during travis executions.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#104](https://github.com/shlinkio/shlink-web-client/issues/104) Fixed blank page being showed when not-found paths are loaded.
* [#94](https://github.com/shlinkio/shlink-web-client/issues/94) Fixed initial zoom and center on maps.
* [#93](https://github.com/shlinkio/shlink-web-client/issues/93) Prevented side menu to be swipeable while a modal window is displayed.
## 2.0.0 - 2019-01-13
#### Added
* [#54](https://github.com/shlinkio/shlink-web-client/issues/54) Added stats by city graphic in visits page.
* [#55](https://github.com/shlinkio/shlink-web-client/issues/55) Added map in visits page locating cities from which visits have occurred.
#### Changed
* [#87](https://github.com/shlinkio/shlink-web-client/issues/87) and [#89](https://github.com/shlinkio/shlink-web-client/issues/89) Updated all dependencies to latest major versions.
* [#96](https://github.com/shlinkio/shlink-web-client/issues/96) Updated visits page to load visits in multiple paginated requests of `5000` visits when used shlink server supports it. This will prevent shlink to hang when trying to load big amounts of visits.
* [#71](https://github.com/shlinkio/shlink-web-client/issues/71) Improved tests and increased code coverage.
#### Deprecated
* *Nothing*
#### Removed
* [#59](https://github.com/shlinkio/shlink-web-client/issues/59) Dropped support for old browsers. Internet explorer and dead browsers are no longer supported.
* [#97](https://github.com/shlinkio/shlink-web-client/issues/97) Dropped support for authentication via `Authorization` header with Bearer type and JWT, which will make this version no longer work with shlink earlier than v1.13.0.
#### Fixed
* *Nothing*
## 1.2.1 - 2018-12-21
#### Added

View File

@@ -1,21 +1,8 @@
FROM nginx:1.15.7-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
FROM node:10.15.3-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && yarn install && yarn build
# Install node and yarn
RUN apk add --no-cache --virtual nodejs && apk add --no-cache --virtual yarn
ADD . ./shlink-web-client
# Install dependencies and build project
RUN cd ./shlink-web-client && \
yarn install && \
yarn build && \
# Move build contents to document root
cd .. && \
rm -r /usr/share/nginx/html/* && \
mv ./shlink-web-client/build/* /usr/share/nginx/html && \
rm -r ./shlink-web-client && \
# Delete and uninstall build tools
yarn cache clean && apk del yarn && apk del nodejs
FROM nginx:1.15.9-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html
COPY --from=node /shlink-web-client/build /usr/share/nginx/html

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 shlinkio
Copyright (c) 2018-2019 shlinkio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,6 +1,7 @@
# shlink-web-client
[![Build Status](https://img.shields.io/travis/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink-web-client)
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)

View File

@@ -43,12 +43,12 @@ dotenvFiles.forEach((dotenvFile) => {
// We support resolving modules according to `NODE_PATH`.
// This lets you use absolute paths in imports inside large monorepos:
// https://github.com/facebookincubator/create-react-app/issues/253.
// https://github.com/facebook/create-react-app/issues/253.
// It works similar to `NODE_PATH` in Node itself:
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421
// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
// We also resolve them to make sure all tools using them work consistently.
const appDirectory = fs.realpathSync(process.cwd());

View File

@@ -6,6 +6,24 @@ const path = require('path');
module.exports = {
process(src, filename) {
return `module.exports = ${JSON.stringify(path.basename(filename))};`;
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.svg$/)) {
return `module.exports = {
__esModule: true,
default: ${assetFilename},
ReactComponent: (props) => ({
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: null,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
}),
};`;
}
return `module.exports = ${assetFilename};`;
},
};

View File

@@ -4,22 +4,22 @@ const fs = require('fs');
const url = require('url');
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebookincubator/create-react-app/issues/637
// https://github.com/facebook/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);
const envPublicUrl = process.env.PUBLIC_URL;
function ensureSlash(path, needsSlash) {
const hasSlash = path.endsWith('/');
function ensureSlash(inputPath, needsSlash) {
const hasSlash = inputPath.endsWith('/');
if (hasSlash && !needsSlash) {
return path.substr(path, path.length - 1);
return inputPath.substr(0, inputPath.length - 1);
} else if (!hasSlash && needsSlash) {
return `${path}/`;
return `${inputPath}/`;
}
return path;
return inputPath;
}
const getPublicUrl = (appPackageJson) =>
@@ -33,23 +33,55 @@ const getPublicUrl = (appPackageJson) =>
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(appPackageJson);
const servedUrl = envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
const servedUrl =
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
return ensureSlash(servedUrl, true);
}
const moduleFileExtensions = [
'web.mjs',
'mjs',
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
];
// Resolve file paths in the same order as webpack
const resolveModule = (resolveFn, filePath) => {
const extension = moduleFileExtensions.find((extension) =>
fs.existsSync(resolveFn(`${filePath}.${extension}`)));
if (extension) {
return resolveFn(`${filePath}.${extension}`);
}
return resolveFn(`${filePath}.js`);
};
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp('build'),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveApp('src/index.js'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveApp('src/setupTests.js'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
appNodeModules: resolveApp('node_modules'),
publicUrl: getPublicUrl(resolveApp('package.json')),
servedPath: getServedPath(resolveApp('package.json')),
};
module.exports.moduleFileExtensions = moduleFileExtensions;

View File

@@ -1,21 +0,0 @@
if (typeof Promise === 'undefined') {
// Rejection tracking prevents a common issue where React gets into an
// inconsistent state due to an error, but it gets swallowed by a Promise,
// and the user has no idea what causes React's erratic future behavior.
require('promise/lib/rejection-tracking').enable();
window.Promise = require('promise/lib/es6-extensions.js');
}
// fetch() polyfill for making API calls.
require('whatwg-fetch');
// Object.assign() is commonly used with React.
// It will use the native implementation if it's present and isn't buggy.
Object.assign = require('object-assign');
// In tests, polyfill requestAnimationFrame since jsdom doesn't provide it yet.
// We don't polyfill it in the browser--this is user's responsibility.
if (process.env.NODE_ENV === 'test') {
require('raf').polyfill(global);
}

View File

@@ -1,308 +0,0 @@
const path = require('path');
const autoprefixer = require('autoprefixer');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const eslintFormatter = require('react-dev-utils/eslintFormatter');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const getClientEnvironment = require('./env');
const paths = require('./paths');
// Webpack uses `publicPath` to determine where the app is being served from.
// In development, we always serve from the root. This makes config easier.
const publicPath = '/';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_PATH%/xyz looks better than %PUBLIC_PATH%xyz.
const publicUrl = '';
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
const postCssLoader = {
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9',
],
flexbox: 'no-2009',
}),
],
},
};
// This is the development configuration.
// It is focused on developer experience and fast rebuilds.
// The production configuration is different and lives in a separate file.
module.exports = {
// You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
devtool: 'cheap-module-source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
entry: [
// We ship a few polyfills by default:
require.resolve('./polyfills'),
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
require.resolve('react-dev-utils/webpackHotDevClient'),
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
],
output: {
// Add /* filename */ comments to generated require()s in the output.
pathinfo: true,
// This does not produce a real file. It's just the virtual path that is
// served by WebpackDevServer in development. This is the JS bundle
// containing code from all our entry points, and the Webpack runtime.
filename: 'static/js/bundle.js',
// There are also additional JS chunk files if you use code splitting.
chunkFilename: 'static/js/[name].chunk.js',
// This is the URL that app is served from. We use "/" in development.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: (info) =>
path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: [ 'node_modules', paths.appNodeModules ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [ '.web.js', '.mjs', '.js', '.json', '.web.jsx', '.jsx' ],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
],
},
module: {
strictExportPresence: true,
rules: [
// TODO: Disable require.ensure as it's not a standard language feature.
// We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
// { parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|jsx|mjs)$/,
enforce: 'pre',
use: [
{
options: {
formatter: eslintFormatter,
eslintPath: require.resolve('eslint'),
},
loader: require.resolve('eslint-loader'),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use a plugin to extract that CSS to a file, but
// in development "style" loader enables hot editing of CSS.
{
test: /\.css$/,
use: [
require.resolve('style-loader'),
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
},
},
postCssLoader,
],
},
{
test: /\.scss$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [ 'css-loader', 'sass-loader', postCssLoader ],
}),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [ /\.(js|jsx|mjs)$/, /\.html$/, /\.json$/ ],
loader: require.resolve('file-loader'),
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
],
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
plugins: [
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env.raw),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appHtml,
}),
// Add module names to factory functions so they appear in browser profiler.
new webpack.NamedModulesPlugin(),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebookincubator/create-react-app/issues/240
new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebookincubator/create-react-app/issues/186
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new ExtractTextPlugin('main.css'),
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
// Turn off performance hints during development because we don't do any
// splitting or minification in interest of speed. These warnings become
// cumbersome.
performance: {
hints: false,
},
};

678
config/webpack.config.js Normal file
View File

@@ -0,0 +1,678 @@
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const resolve = require('resolve');
const PnpWebpackPlugin = require('pnp-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const safePostCssParser = require('postcss-safe-parser');
const ManifestPlugin = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt');
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
const getClientEnvironment = require('./env');
const paths = require('./paths');
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
// makes for a smoother build process.
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
/* eslint-disable complexity */
module.exports = (webpackEnv) => {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// In development, we always serve from the root. This makes config easier.
const publicPath = isEnvProduction
? paths.servedPath
: isEnvDevelopment && '/';
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = isEnvProduction
? publicPath.slice(0, -1)
: isEnvDevelopment && '';
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
options: Object.assign(
{},
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
),
},
{
loader: require.resolve('css-loader'),
options: cssOptions,
},
{
// Options for PostCSS as we reference these options twice
// Adds vendor prefixing based on your specified browser support in
// package.json
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebook/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
}),
],
sourceMap: isEnvProduction && shouldUseSourceMap,
},
},
].filter(Boolean);
if (preProcessor) {
loaders.push({
loader: require.resolve(preProcessor),
options: {
sourceMap: isEnvProduction && shouldUseSourceMap,
},
});
}
return loaders;
};
return {
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? 'source-map'
: false
: isEnvDevelopment && 'cheap-module-source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: [
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
isEnvDevelopment &&
require.resolve('react-dev-utils/webpackHotDevClient'),
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
].filter(Boolean),
output: {
// The build folder.
path: isEnvProduction ? paths.appBuild : undefined,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? 'static/js/[name].[chunkhash:8].js'
: isEnvDevelopment && 'static/js/bundle.js',
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? 'static/js/[name].[chunkhash:8].chunk.js'
: isEnvDevelopment && 'static/js/[name].chunk.js',
// We inferred the "public path" (such as / or /my-project) from homepage.
// We use "/" in development.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, '/')
: isEnvDevelopment &&
((info) => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// we want terser to parse ecma 8 code. However, we don't want it
// to apply any minfication steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending futher investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2,
},
mangle: {
safari10: true,
},
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
// Use multi-process parallel running to improve the build speed
// Default number of concurrent runs: os.cpus().length - 1
parallel: true,
// Enable file caching
cache: true,
sourceMap: shouldUseSourceMap,
}),
// This is only used in production mode
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
parser: safePostCssParser,
map: shouldUseSourceMap
? {
// `inline: false` forces the sourcemap to be output into a
// separate file
inline: false,
// `annotation: true` appends the sourceMappingURL to the end of
// the css file, helping the browser find the sourcemap
annotation: true,
}
: false,
},
}),
],
// Automatically split vendor and commons
// https://twitter.com/wSokra/status/969633336732905474
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
splitChunks: {
chunks: 'all',
name: false,
},
// Keep the runtime chunk separated to enable long term caching
// https://twitter.com/wSokra/status/969679223278505985
runtimeChunk: true,
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: [ 'node_modules' ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes('ts')),
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
},
plugins: [
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
// guards against forgotten dependencies and such.
PnpWebpackPlugin,
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|mjs|jsx)$/,
enforce: 'pre',
use: [
{
options: {
formatter: require.resolve('react-dev-utils/eslintFormatter'),
eslintPath: require.resolve('eslint'),
},
loader: require.resolve('eslint-loader'),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve(
'babel-preset-react-app/webpack-overrides'
),
plugins: [
[
require.resolve('babel-plugin-named-asset-import'),
{
loaderMap: {
svg: {
ReactComponent:
'@svgr/webpack?-prettier,-svgo![path]',
},
},
},
],
],
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
cacheCompression: isEnvProduction,
compact: isEnvProduction,
},
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve('babel-loader'),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [
[
require.resolve('babel-preset-react-app/dependencies'),
{ helpers: true },
],
],
cacheDirectory: true,
cacheCompression: isEnvProduction,
// If an error happens in a package, it's possible to be
// because it was compiled. Thus, we don't want the browser
// debugger to show the original code. Instead, the code
// being evaluated would be much more helpful.
sourceMaps: false,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction && shouldUseSourceMap,
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
}),
},
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
},
'sass-loader'
),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
},
'sass-loader'
),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve('file-loader'),
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [ /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/ ],
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [ /runtime~.+[.]js/ ]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
// In development, this will be an empty string.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
}),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: 'asset-manifest.json',
publicPath,
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the Webpack build.
isEnvProduction &&
new WorkboxWebpackPlugin.GenerateSW({
clientsClaim: true,
exclude: [ /\.map$/, /asset-manifest\.json$/ ],
importWorkboxFrom: 'cdn',
navigateFallback: `${publicUrl}/index.html`,
navigateFallbackBlacklist: [
// Exclude URLs starting with /_, as they're likely an API call
new RegExp('^/_'),
// Exclude URLs containing a dot, as they're likely a resource in
// public/ and not a SPA route
new RegExp('/[^/]+\\.[^/]+$'),
],
}),
// TypeScript type checking
useTypeScript &&
new ForkTsCheckerWebpackPlugin({
typescript: resolve.sync('typescript', {
basedir: paths.appNodeModules,
}),
async: false,
checkSyntacticErrors: true,
tsconfig: paths.appTsConfig,
compilerOptions: {
module: 'esnext',
moduleResolution: 'node',
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
jsx: 'preserve',
},
reportFiles: [
'**',
'!**/*.json',
'!**/__tests__/**',
'!**/?(*.)(spec|test).*',
'!**/src/setupProxy.*',
'!**/src/setupTests.*',
],
watch: paths.appSrc,
silent: true,
formatter: typescriptFormatter,
}),
].filter(Boolean),
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
};
};

View File

@@ -1,385 +0,0 @@
const path = require('path');
const autoprefixer = require('autoprefixer');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
const eslintFormatter = require('react-dev-utils/eslintFormatter');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const paths = require('./paths');
const getClientEnvironment = require('./env');
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
const publicPath = paths.servedPath;
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './';
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = publicPath.slice(0, -1);
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// Assert this just to be safe.
// Development builds of React are slow and not intended for production.
if (env.stringified['process.env'].NODE_ENV !== '"production"') {
throw new Error('Production builds must have NODE_ENV=production.');
}
// Note: defined here because it will be used more than once.
const cssFilename = 'static/css/[name].[contenthash:8].css';
// ExtractTextPlugin expects the build output to be flat.
// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
// However, our output is structured with css, js and media folders.
// To have this structure working with relative paths, we have to use custom options.
// Making sure that the publicPath goes back to to build folder.
const extractTextPluginOptions = shouldUseRelativeAssetPaths
? { publicPath: Array(cssFilename.split('/').length).join('../') }
: {};
// This is the production configuration.
// It compiles slowly and is focused on producing a fast and minimal bundle.
// The development configuration is different and lives in a separate file.
module.exports = {
// Don't attempt to continue if there are any errors.
bail: true,
// We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
devtool: shouldUseSourceMap ? 'source-map' : false,
// In production, we only want to load the polyfills and the app code.
entry: [ require.resolve('./polyfills'), paths.appIndexJs ],
output: {
// The build folder.
path: paths.appBuild,
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
// We don't currently advertise code splitting but Webpack supports it.
filename: 'static/js/[name].[chunkhash:8].js',
chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, '/'),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: [ 'node_modules', paths.appNodeModules ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [ '.web.js', '.mjs', '.js', '.json', '.web.jsx', '.jsx' ],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
],
},
module: {
strictExportPresence: true,
rules: [
// TODO: Disable require.ensure as it's not a standard language feature.
// We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
// { parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|jsx|mjs)$/,
enforce: 'pre',
use: [
{
options: {
formatter: eslintFormatter,
eslintPath: require.resolve('eslint'),
},
loader: require.resolve('eslint-loader'),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works just like "file" loader but it also embeds
// assets smaller than specified size as data URLs to avoid requests.
{
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
compact: true,
},
},
// The notation here is somewhat confusing.
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader normally turns CSS into JS modules injecting <style>,
// but unlike in development configuration, we do something different.
// `ExtractTextPlugin` first applies the "postcss" and "css" loaders
// (second argument), then grabs the result CSS and puts it into a
// separate file in our build process. This way we actually ship
// a single CSS file in production instead of JS code injecting <style>
// tags. If you use code splitting, however, any async bundles will still
// use the "style" loader inside the async code so CSS from them won't be
// in the main CSS file.
{
test: [ /\.css$/, /\.scss$/ ],
loader: ExtractTextPlugin.extract(
Object.assign(
{
fallback: {
loader: require.resolve('style-loader'),
options: {
hmr: false,
},
},
use: [
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
minimize: true,
sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve('sass-loader'),
},
{
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9',
],
flexbox: 'no-2009',
}),
],
},
},
],
},
extractTextPluginOptions
)
),
// Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
},
// "file" loader makes sure assets end up in the `build` folder.
// When you `import` an asset, you get its filename.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve('file-loader'),
// Exclude `js` files to keep "css" loader working as it injects
// it's runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [ /\.(js|jsx|mjs)$/, /\.html$/, /\.json$/ ],
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(env.raw),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appHtml,
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV was set to production here.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// Minify the code.
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebookincubator/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
},
mangle: {
safari10: true,
},
output: {
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebookincubator/create-react-app/issues/2488
ascii_only: true,
},
sourceMap: shouldUseSourceMap,
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
filename: cssFilename,
}),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: 'asset-manifest.json',
}),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the Webpack build.
new SWPrecacheWebpackPlugin({
// By default, a cache-busting query parameter is appended to requests
// used to populate the caches, to ensure the responses are fresh.
// If a URL is already hashed by Webpack, then there is no concern
// about it being stale, and the cache-busting can be skipped.
dontCacheBustUrlsMatching: /\.\w{8}\./,
filename: 'service-worker.js',
logger(message) {
if (message.indexOf('Total precache size is') === 0) {
// This message occurs for every build and is a bit too noisy.
return;
}
if (message.indexOf('Skipping static resource') === 0) {
// This message obscures real errors so we ignore it.
// https://github.com/facebookincubator/create-react-app/issues/2612
return;
}
console.log(message); // eslint-disable-line
},
minify: true,
// For unknown URLs, fallback to the index page
navigateFallback: `${publicUrl}/index.html`,
// Ignores URLs starting from /__ (useful for Firebase):
// https://github.com/facebookincubator/create-react-app/issues/2237#issuecomment-302693219
navigateFallbackWhitelist: [ /^(?!\/__).*/ ],
// Don't precache sourcemaps (they're large) and build asset manifest:
staticFileGlobsIgnorePatterns: [ /\.map$/, /asset-manifest\.json$/ ],
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
};

View File

@@ -1,8 +1,9 @@
const fs = require('fs');
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware');
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
const ignoredFiles = require('react-dev-utils/ignoredFiles');
const config = require('./webpack.config.dev');
const paths = require('./paths');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
@@ -17,8 +18,8 @@ module.exports = function(proxy, allowedHost) {
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebookincubator/create-react-app/issues/2271
// https://github.com/facebookincubator/create-react-app/issues/2233
// https://github.com/facebook/create-react-app/issues/2271
// https://github.com/facebook/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
@@ -65,16 +66,16 @@ module.exports = function(proxy, allowedHost) {
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: config.output.publicPath,
publicPath: '/',
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.plugin` calls above.
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
quiet: true,
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebookincubator/create-react-app/issues/293
// https://github.com/facebook/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebookincubator/create-react-app/issues/1065
// https://github.com/facebook/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
@@ -86,12 +87,20 @@ module.exports = function(proxy, allowedHost) {
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387.
// See https://github.com/facebook/create-react-app/issues/387.
disableDotRule: true,
},
public: allowedHost,
proxy,
before(app) {
before(app, server) {
if (fs.existsSync(paths.proxySetup)) {
// This registers user provided middleware for proxy reasons
require(paths.proxySetup)(app);
}
// This lets us fetch source contents from webpack for the error overlay
app.use(evalSourceMapMiddleware(server));
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
@@ -99,7 +108,7 @@ module.exports = function(proxy, allowedHost) {
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebookincubator/create-react-app/issues/2272#issuecomment-302832432
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware());
},
};

View File

@@ -3,7 +3,7 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:10.4.1-alpine
image: node:10.15.0-alpine
command: /bin/sh -c "cd /home/shlink/www && yarn install && yarn start"
volumes:
- ./:/home/shlink/www

View File

@@ -8,29 +8,37 @@ module.exports = {
'!src/**/provideServices.js',
'!src/container/*.js',
],
resolver: 'jest-pnp-resolver',
setupFiles: [
'<rootDir>/config/polyfills.js',
'react-app-polyfill/jsdom',
'<rootDir>/config/setupEnzyme.js',
],
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,mjs}' ],
testEnvironment: 'node',
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
testEnvironment: 'jsdom',
testURL: 'http://localhost',
transform: {
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$' ],
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$',
],
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
},
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node',
'mjs',
],
};

View File

@@ -18,28 +18,34 @@
"test:pretty": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.8",
"@fortawesome/fontawesome-free-regular": "^5.0.13",
"@fortawesome/fontawesome-free-solid": "^5.0.13",
"@fortawesome/react-fontawesome": "0.0.19",
"@fortawesome/fontawesome-free": "^5.6.3",
"@fortawesome/fontawesome-svg-core": "^1.2.0",
"@fortawesome/free-regular-svg-icons": "^5.6.3",
"@fortawesome/free-solid-svg-icons": "^5.6.3",
"@fortawesome/react-fontawesome": "^0.1.3",
"array-filter": "^1.0.0",
"array-map": "^0.0.0",
"array-reduce": "^0.0.0",
"axios": "^0.18.0",
"bootstrap": "^4.1.1",
"bootstrap": "^4.3.1",
"bottlejs": "^1.7.1",
"chart.js": "^2.7.2",
"classnames": "^2.2.6",
"csvjson": "^5.1.0",
"leaflet": "^1.4.0",
"moment": "^2.22.2",
"promise": "8.0.1",
"promise": "^8.0.1",
"prop-types": "^15.6.2",
"qs": "^6.5.2",
"ramda": "^0.25.0",
"react": "^16.6",
"ramda": "^0.26.1",
"react": "^16.8.0",
"react-autosuggest": "^9.4.0",
"react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "^1.5.0",
"react-dom": "^16.3.2",
"react-datepicker": "~1.5.0",
"react-dom": "^16.8.0",
"react-leaflet": "^2.2.1",
"react-moment": "^0.7.6",
"react-redux": "^5.0.7",
"react-router-dom": "^4.2.2",
@@ -51,63 +57,86 @@
"uuid": "^3.3.2"
},
"devDependencies": {
"adm-zip": "^0.4.11",
"autoprefixer": "7.1.6",
"babel-core": "6.26.0",
"babel-eslint": "7.2.3",
"babel-jest": "20.0.3",
"babel-loader": "^7.1.2",
"babel-preset-react-app": "^3.1.1",
"babel-runtime": "6.26.0",
"case-sensitive-paths-webpack-plugin": "2.1.1",
"chalk": "1.1.3",
"css-loader": "0.28.7",
"dotenv": "4.0.0",
"dotenv-expand": "4.2.0",
"@babel/core": "^7.1.6",
"@svgr/webpack": "^2.4.1",
"adm-zip": "0.4.11",
"autoprefixer": "^7.1.6",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"babel-plugin-named-asset-import": "^0.3.0",
"babel-preset-react-app": "^7.0.0",
"babel-runtime": "^6.26.0",
"bfj": "^6.1.1",
"case-sensitive-paths-webpack-plugin": "^2.1.2",
"chalk": "^2.4.1",
"css-loader": "^1.0.0",
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"eslint": "^4.19.0",
"eslint-config-adidas-babel": "^1.0.1",
"eslint-config-adidas-env": "^1.0.1",
"eslint-config-adidas-es6": "^1.0.1",
"eslint-config-adidas-react": "^1.0.1",
"eslint-loader": "1.9.0",
"eslint-plugin-import": "^2.8.0",
"eslint": "^5.11.1",
"eslint-config-adidas-babel": "^1.1.0",
"eslint-config-adidas-env": "^1.1.0",
"eslint-config-adidas-es6": "^1.2.0",
"eslint-config-adidas-react": "^1.1.1",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jest": "^21.22.0",
"eslint-plugin-promise": "^3.0.0",
"eslint-plugin-react": "^7.4.0",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "1.1.5",
"fs-extra": "3.0.1",
"html-webpack-plugin": "2.29.0",
"jest": "20.0.4",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-react": "^7.11.1",
"file-loader": "^2.0.0",
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
"fs-extra": "^7.0.0",
"html-webpack-plugin": "^4.0.0-alpha.2",
"identity-obj-proxy": "^3.0.0",
"jest": "^23.6.0",
"jest-pnp-resolver": "^1.0.1",
"jest-resolve": "^23.6.0",
"mini-css-extract-plugin": "^0.4.3",
"node-sass": "^4.9.0",
"object-assign": "4.1.1",
"object-assign": "^4.1.1",
"ocular.js": "^0.1.0",
"postcss-flexbugs-fixes": "3.2.0",
"postcss-loader": "2.0.8",
"raf": "3.4.0",
"react-dev-utils": "^5.0.1",
"resolve": "1.6.0",
"sass-loader": "^7.0.1",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"pnp-webpack-plugin": "^1.1.0",
"postcss": "^7.0.7",
"postcss-flexbugs-fixes": "^4.1.0",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.3.1",
"postcss-safe-parser": "^4.0.1",
"raf": "^3.4.0",
"react-app-polyfill": "^0.2.0",
"react-dev-utils": "^7.0.1",
"resolve": "^1.8.1",
"sass-loader": "^7.1.0",
"serve": "^10.0.0",
"sinon": "^6.1.5",
"style-loader": "0.19.0",
"stylelint": "^9.5.0",
"stylelint-config-adidas": "^1.0.1",
"stylelint-config-adidas-bem": "^1.0.1",
"style-loader": "^0.23.0",
"stylelint": "^9.9.0",
"stylelint-config-adidas": "^1.2.1",
"stylelint-config-adidas-bem": "^1.2.0",
"stylelint-config-recommended-scss": "^3.2.0",
"stylelint-scss": "^3.3.0",
"sw-precache-webpack-plugin": "0.11.4",
"url-loader": "0.6.2",
"webpack": "3.8.1",
"webpack-dev-server": "2.9.4",
"webpack-manifest-plugin": "1.3.2",
"whatwg-fetch": "2.0.3"
"sw-precache-webpack-plugin": "^0.11.4",
"terser-webpack-plugin": "^1.1.0",
"url-loader": "^1.1.1",
"webpack": "^4.19.1",
"webpack-dev-server": "^3.1.14",
"webpack-manifest-plugin": "^2.0.4",
"whatwg-fetch": "^2.0.3",
"workbox-webpack-plugin": "^3.6.3"
},
"babel": {
"presets": [
"react-app"
]
}
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}

View File

@@ -1,4 +1,4 @@
/* eslint no-console: "off" */
/* eslint-disable no-console */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
@@ -18,14 +18,16 @@ const path = require('path');
const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const AdmZip = require('adm-zip');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
const paths = require('../config/paths');
const config = require('../config/webpack.config.prod');
const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
const useYarn = fs.existsSync(paths.yarnLockFile);
@@ -34,14 +36,27 @@ const useYarn = fs.existsSync(paths.yarnLockFile);
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // eslint-disable-line
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
process.exit(1);
}
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appBuild)
// Process CLI arguments
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.indexOf('--stats') !== -1;
// Generate configuration
const config = configFactory('production');
checkBrowsers(paths.appPath, isInteractive)
.then(() =>
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appBuild))
.then((previousFileSizes) => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
@@ -85,7 +100,7 @@ measureFileSizesBeforeBuild(paths.appBuild)
const appPackage = require(paths.appPackageJson);
const { publicUrl } = paths;
const { publicPath } = config.output;
const { output: { publicPath } } = config;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
@@ -102,7 +117,14 @@ measureFileSizesBeforeBuild(paths.appBuild)
process.exit(1);
}
)
.then(zipDist);
.then(zipDist)
.catch((err) => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
@@ -112,12 +134,22 @@ function build(previousFileSizes) {
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
if (err) {
return reject(err);
if (!err.message) {
return reject(err);
}
messages = formatWebpackMessages({
errors: [ err.message ],
warnings: [],
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true })
);
}
const messages = formatWebpackMessages(stats.toJson({}, true));
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
@@ -143,11 +175,20 @@ function build(previousFileSizes) {
return reject(new Error(messages.warnings.join('\n\n')));
}
return resolve({
const resolveArgs = {
stats,
previousFileSizes,
warnings: messages.warnings,
});
};
if (writeStatsJson) {
return bfj // eslint-disable-line promise/no-promise-in-callback
.write(`${paths.appBuild}/bundle-stats.json`, stats.toJson())
.then(() => resolve(resolveArgs))
.catch((error) => reject(new Error(error)));
}
return resolve(resolveArgs);
});
});
}

View File

@@ -1,4 +1,4 @@
/* eslint no-console: "off" */
/* eslint-disable no-console */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
@@ -27,8 +27,9 @@ const {
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const openBrowser = require('react-dev-utils/openBrowser');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
const paths = require('../config/paths');
const config = require('../config/webpack.config.dev');
const configFactory = require('../config/webpack.config');
const createDevServerConfig = require('../config/webpackDevServer.config');
const useYarn = fs.existsSync(paths.yarnLockFile);
@@ -40,8 +41,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
}
// Tools like Cloud9 rely on this.
const FALLBACK_PORT = 3000;
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || FALLBACK_PORT;
const DEFAULT_PORT = 3000;
const PORT = parseInt(process.env.PORT) || DEFAULT_PORT;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {
@@ -55,19 +56,25 @@ if (process.env.HOST) {
console.log(
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.'
);
console.log(`Learn more here: ${chalk.yellow('http://bit.ly/2mwWSwH')}`);
console.log(
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`
);
console.log();
}
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, DEFAULT_PORT)
checkBrowsers(paths.appPath, isInteractive)
.then(() =>
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, PORT))
.then((port) => {
if (port === null) {
// We have not found a port.
return;
}
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
@@ -81,7 +88,7 @@ choosePort(HOST, DEFAULT_PORT)
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
// Serve webpack assets generated by the compiler over a web sever.
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
@@ -89,19 +96,17 @@ choosePort(HOST, DEFAULT_PORT)
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, (err) => {
if (err) {
console.log(err);
return;
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
console.log(chalk.cyan('Starting the development server...\n'));
openBrowser(urls.localUrlForBrowser);
return openBrowser(urls.localUrlForBrowser);
});
[ 'SIGINT', 'SIGTERM' ].forEach((sig) => {

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import './App.scss';
import NotFound from './common/NotFound';
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
<div className="container-fluid app-container">
@@ -11,6 +12,7 @@ const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
</div>
</div>

View File

@@ -1,7 +1,5 @@
import listIcon from '@fortawesome/fontawesome-free-solid/faList';
import createIcon from '@fortawesome/fontawesome-free-solid/faLink';
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';

View File

@@ -1,5 +1,4 @@
@import '../utils/base';
@import '../utils/mixins/box-shadow';
@import '../utils/mixins/vertical-align';
$asideMenuMobileWidth: 280px;
@@ -26,8 +25,7 @@ $asideMenuMobileWidth: 280px;
width: $asideMenuMobileWidth !important;
transition: left 300ms;
top: $headerHeight - 3px;
@include box-shadow(-10px 0 50px 11px rgba(0, 0, 0, .55));
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
}
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import './ErrorHandler.scss';
import { Button } from 'reactstrap';
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
};
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(e) {
if (process.env.NODE_ENV !== 'development') {
error(e);
}
}
render() {
if (this.state.hasError) {
return (
<div className="error-handler">
<h1>Oops! This is awkward :S</h1>
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br />
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
</div>
);
}
return this.props.children;
}
};
export default ErrorHandler;

View File

@@ -0,0 +1,9 @@
@import '../utils/mixins/vertical-align.scss';
.error-handler {
@include vertical-align();
padding: 20px;
text-align: center;
width: 100%;
}

View File

@@ -1,5 +1,5 @@
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { Link } from 'react-router-dom';

View File

@@ -1,6 +1,5 @@
import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { Link } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import Swipeable from 'react-swipeable';
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import NotFound from './NotFound';
import './MenuLayout.scss';
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
@@ -38,10 +39,18 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
}
render() {
const { selectedServer } = this.props;
const { selectedServer, match } = this.props;
const { params: { serverId } } = match;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
if (document.querySelector('.modal')) {
return;
}
this.setState({ showSideBar });
};
return (
<React.Fragment>
@@ -54,8 +63,8 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={() => this.setState({ showSideBar: false })}
onSwipedRight={() => this.setState({ showSideBar: true })}
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
@@ -88,6 +97,9 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
path="/server/:serverId/manage-tags"
component={TagsList}
/>
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/>
</Switch>
</div>
</div>

23
src/common/NotFound.js Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Link } from 'react-router-dom';
import * as PropTypes from 'prop-types';
const propTypes = {
to: PropTypes.string,
btnText: PropTypes.string,
};
const NotFound = ({ to = '/', btnText = 'Home' }) => (
<div className="home">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button.
</p>
<br />
<Link to={to} className="btn btn-outline-primary btn-lg">{btnText}</Link>
</div>
);
NotFound.propTypes = propTypes;
export default NotFound;

View File

@@ -1,17 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
const ScrollToTop = (window) => class ScrollToTop extends React.Component {
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
children: PropTypes.node,
};
componentDidUpdate(prevProps) {
componentDidUpdate({ location: prevLocation }) {
const { location } = this.props;
if (location !== prevProps.location) {
window.scrollTo(0, 0);
if (location !== prevLocation) {
scrollTo(0, 0);
}
}

View File

@@ -27,12 +27,12 @@
.react-tagsinput-remove {
cursor: pointer;
font-weight: bold;
font-weight: 700;
margin-left: 8px;
}
.react-tagsinput-tag span:before {
content: "\2715";
content: '\2715';
color: #fff;
}

View File

@@ -3,9 +3,11 @@ import MainHeader from '../MainHeader';
import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window);
bottle.constant('console', global.console);
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
bottle.decorator('ScrollToTop', withRouter);
@@ -29,6 +31,8 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};
export default provideServices;

View File

@@ -13,11 +13,12 @@ import provideUtilsServices from '../utils/services/provideServices';
const bottle = new Bottle();
const { container } = bottle;
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
const mapActionService = (map, actionName) => ({
...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: (...args) => container[actionName](...args),
[actionName]: lazyService(container, actionName),
});
const connect = (propsFromState, actionServiceNames) =>
reduxConnect(

View File

@@ -7,18 +7,25 @@ import { homepage } from '../package.json';
import registerServiceWorker from './registerServiceWorker';
import container from './container';
import store from './container/store';
import '../node_modules/react-datepicker/dist/react-datepicker.css';
import { fixLeafletIcons } from './utils/utils';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
import './common/react-tagsinput.scss';
import './index.scss';
const { App, ScrollToTop } = container;
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
fixLeafletIcons();
const { App, ScrollToTop, ErrorHandler } = container;
render(
<Provider store={store}>
<BrowserRouter basename={homepage}>
<ScrollToTop>
<App />
</ScrollToTop>
<ErrorHandler>
<ScrollToTop>
<App />
</ScrollToTop>
</ErrorHandler>
</BrowserRouter>
</Provider>,
document.getElementById('root')

View File

@@ -51,3 +51,11 @@ body,
margin: 0 auto !important;
}
}
.pagination .page-link {
cursor: pointer;
}
.paddingless {
padding: 0;
}

View File

@@ -43,22 +43,22 @@ export default function register() {
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
return navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
'worker. To learn more, visit https://goo.gl/SC7cgQ'
);
});
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
// Is not local host. Just register service worker
return registerValidSW(swUrl);
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
return navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
@@ -99,15 +99,14 @@ function checkValidServiceWorker(swUrl) {
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
return navigator.serviceWorker.ready.then((registration) =>
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}));
}
// Service worker found. Proceed as normal.
return registerValidSW(swUrl);
})
.catch(() => {
console.log(
@@ -120,6 +119,6 @@ export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister();
});
}).catch(() => {});
}
}

View File

@@ -2,12 +2,11 @@ import { assoc, dissoc, pipe } from 'ramda';
import React from 'react';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { stateFlagTimeout } from '../utils/utils';
import './CreateServer.scss';
const SHOW_IMPORT_MSG_TIME = 4000;
const CreateServer = (ImportServersBtn) => class CreateServer extends React.Component {
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
static propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({

View File

@@ -5,7 +5,7 @@
}
.create-server__label {
font-weight: bold;
font-weight: 700;
cursor: pointer;
@media (min-width: $mdMin) {

View File

@@ -1,5 +1,5 @@
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types';
import { serverType } from './prop-types';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc } from 'ramda';
import { assoc, map } from 'ramda';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
@@ -22,11 +22,16 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
render() {
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const onChange = (e) =>
importServersFromFile(e.target.files[0])
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
const assocId = (server) => assoc('id', uuid(), server);
const onChange = ({ target }) =>
importServersFromFile(target.files[0])
.then(map(assocId))
.then(createServers)
.then(onImport);
.then(onImport)
.then(() => {
// Reset input after processing file
target.value = null;
});
return (
<React.Fragment>

View File

@@ -14,20 +14,20 @@ export const listServers = (serversService) => () => ({
servers: serversService.listServers(),
});
export const createServer = (serversService) => (server) => {
export const createServer = (serversService, listServers) => (server) => {
serversService.createServer(server);
return listServers(serversService)();
return listServers();
};
export const deleteServer = (serversService) => (server) => {
export const deleteServer = (serversService, listServers) => (server) => {
serversService.deleteServer(server);
return listServers(serversService)();
return listServers();
};
export const createServers = (serversService) => (servers) => {
export const createServers = (serversService, listServers) => (servers) => {
serversService.createServers(servers);
return listServers(serversService)();
return listServers();
};

View File

@@ -12,7 +12,7 @@ import ServersExporter from './ServersExporter';
const provideServices = (bottle, connect, withRouter) => {
// Components
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn');
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout');
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
@@ -35,9 +35,9 @@ const provideServices = (bottle, connect, withRouter) => {
// Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService');
bottle.serviceFactory('createServer', createServer, 'ServersService');
bottle.serviceFactory('createServers', createServers, 'ServersService');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);

View File

@@ -1,18 +1,16 @@
import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown';
import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isNil, pipe, replace, trim } from 'ramda';
import React from 'react';
import { Collapse } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
const CreateShortUrl = (TagsSelector) => class CreateShortUrl extends React.Component {
const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShortUrl extends React.Component {
static propTypes = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types';
import { range } from 'ramda';
import { rangeOf } from '../utils/utils';
const propTypes = {
serverId: PropTypes.string.isRequired,
@@ -20,7 +20,7 @@ export default function Paginator({ paginator = {}, serverId }) {
}
const renderPages = () =>
range(1, pagesCount + 1).map((pageNumber) => (
rangeOf(pagesCount, (pageNumber) => (
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
<PaginationLink
tag={Link}

View File

@@ -1,5 +1,5 @@
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { isEmpty } from 'ramda';
import PropTypes from 'prop-types';

View File

@@ -1,21 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import Paginator from './Paginator';
const ShortUrls = (SearchBar, ShortUrlsList) => (props) => {
const { match: { params }, shortUrlsList } = props;
const { page, serverId } = params;
const { data = [], pagination } = shortUrlsList;
const ShortUrls = (SearchBar, ShortUrlsList) => {
const propTypes = {
match: PropTypes.shape({
params: PropTypes.object,
}),
shortUrlsList: PropTypes.object,
};
// Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${serverId}_${page}`;
const ShortUrlsComponent = (props) => {
const { match: { params }, shortUrlsList } = props;
const { page, serverId } = params;
const { data = [], pagination } = shortUrlsList;
return (
<div className="shlink-container">
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
);
// Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${serverId}_${page}`;
return (
<div className="shlink-container">
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
);
};
ShortUrlsComponent.propTypes = propTypes;
return ShortUrlsComponent;
};
export default ShortUrls;

View File

@@ -1,6 +1,5 @@
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, values } from 'ramda';
import React from 'react';
import qs from 'qs';
@@ -15,7 +14,7 @@ import './ShortUrlsList.scss';
const SORTABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
originalUrl: 'Long URL',
longUrl: 'Long URL',
visits: 'Visits',
};
@@ -143,9 +142,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('originalUrl')}
onClick={this.orderByColumn('longUrl')}
>
{this.renderOrderIcon('originalUrl')}
{this.renderOrderIcon('longUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>

View File

@@ -1,15 +1,14 @@
import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Card, CardBody, Tooltip } from 'reactstrap';
import PropTypes from 'prop-types';
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
import { stateFlagTimeout } from '../../utils/utils';
import './CreateShortUrlResult.scss';
export default class CreateShortUrlResult extends React.Component {
const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult extends React.Component {
static propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
@@ -61,4 +60,6 @@ export default class CreateShortUrlResult extends React.Component {
</Card>
);
}
}
};
export default CreateShortUrlResult;

View File

@@ -6,11 +6,14 @@ import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
import { stateFlagTimeout } from '../../utils/utils';
import Tag from '../../tags/helpers/Tag';
import './ShortUrlsRow.scss';
const ShortUrlsRow = (ShortUrlsRowMenu, colorGenerator) => class ShortUrlsRow extends React.Component {
const ShortUrlsRow = (
ShortUrlsRowMenu,
colorGenerator,
stateFlagTimeout
) => class ShortUrlsRow extends React.Component {
static propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
@@ -40,7 +43,6 @@ const ShortUrlsRow = (ShortUrlsRowMenu, colorGenerator) => class ShortUrlsRow ex
render() {
const { shortUrl, selectedServer } = this.props;
const completeShortUrl = !selectedServer ? shortUrl.shortCode : `${selectedServer.url}/${shortUrl.shortCode}`;
return (
<tr className="short-urls-row">
@@ -48,10 +50,10 @@ const ShortUrlsRow = (ShortUrlsRowMenu, colorGenerator) => class ShortUrlsRow ex
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td>
<td className="short-urls-row__cell" data-th="Short URL: ">
<ExternalLink href={completeShortUrl}>{completeShortUrl}</ExternalLink>
<ExternalLink href={shortUrl.shortUrl} />
</td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<ExternalLink href={shortUrl.originalUrl}>{shortUrl.originalUrl}</ExternalLink>
<ExternalLink href={shortUrl.longUrl} />
</td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td>
@@ -63,7 +65,6 @@ const ShortUrlsRow = (ShortUrlsRowMenu, colorGenerator) => class ShortUrlsRow ex
Copied short URL!
</small>
<ShortUrlsRowMenu
completeShortUrl={completeShortUrl}
selectedServer={selectedServer}
shortUrl={shortUrl}
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}

View File

@@ -1,11 +1,12 @@
import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy';
import pictureIcon from '@fortawesome/fontawesome-free-regular/faImage';
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie';
import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV';
import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode';
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon, faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
import {
faTags as tagsIcon,
faChartPie as pieChartIcon,
faEllipsisV as menuIcon,
faQrcode as qrIcon,
faMinusCircle as deleteIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
@@ -19,7 +20,6 @@ import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
completeShortUrl: PropTypes.string,
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
shortUrl: shortUrlType,
@@ -28,28 +28,28 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
state = {
isOpen: false,
isQrModalOpen: false,
isPreviewOpen: false,
isPreviewModalOpen: false,
isTagsModalOpen: false,
isDeleteModalOpen: false,
};
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() {
const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props;
const serverId = selectedServer ? selectedServer.id : '';
const { onCopyToClipboard, shortUrl, selectedServer: { id } } = this.props;
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewOpen');
const togglePreview = toggleModal('isPreviewModalOpen');
const toggleTags = toggleModal('isTagsModalOpen');
const toggleDelete = toggleModal('isDeleteModalOpen');
return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
<DropdownItem tag={Link} to={`/server/${id}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
</DropdownItem>
@@ -66,31 +66,19 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} /> &nbsp;Delete short URL
</DropdownItem>
<DeleteShortUrlModal
shortUrl={shortUrl}
isOpen={this.state.isDeleteModalOpen}
toggle={toggleDelete}
/>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
<DropdownItem divider />
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} /> &nbsp;Preview
</DropdownItem>
<PreviewModal
url={completeShortUrl}
isOpen={this.state.isPreviewOpen}
toggle={togglePreview}
/>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} /> &nbsp;QR code
</DropdownItem>
<QrCodeModal
url={completeShortUrl}
isOpen={this.state.isQrModalOpen}
toggle={toggleQrCode}
/>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
<DropdownItem divider />

View File

@@ -10,9 +10,10 @@ export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlType = PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
shortCode: PropTypes.string,
originalUrl: PropTypes.string,
shortUrl: PropTypes.string,
longUrl: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
});
const initialState = {

View File

@@ -8,6 +8,7 @@ import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
@@ -30,11 +31,12 @@ const provideServices = (bottle, connect) => {
[ 'listShortUrls', 'resetShortUrlParams' ]
));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult' ], [ 'createShortUrl', 'resetCreateShortUrl' ])

View File

@@ -1,7 +1,6 @@
import { Card, CardBody } from 'reactstrap';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import deleteIcon from '@fortawesome/fontawesome-free-solid/faTrash';
import editIcon from '@fortawesome/fontawesome-free-solid/faPencilAlt';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';

View File

@@ -13,13 +13,12 @@ export default class DeleteTagConfirmModal extends React.Component {
tagDeleted: PropTypes.func,
};
doDelete = () => {
doDelete = async () => {
const { tag, toggle, deleteTag } = this.props;
deleteTag(tag).then(() => {
this.tagWasDeleted = true;
toggle();
});
await deleteTag(tag);
this.tagWasDeleted = true;
toggle();
};
handleOnClosed = () => {
if (!this.tagWasDeleted) {

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { ChromePicker } from 'react-color';
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import './EditTagModal.scss';

View File

@@ -10,6 +10,7 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
listTags: PropTypes.func,
placeholder: PropTypes.string,
tagsList: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { isNil } from 'ramda';
import DatePicker from 'react-datepicker';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
import * as PropTypes from 'prop-types';
import './DateInput.scss';

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import * as PropTypes from 'prop-types';
const propTypes = {
toggleClassName: PropTypes.string,
ranges: PropTypes.arrayOf(PropTypes.number).isRequired,
value: PropTypes.number.isRequired,
setValue: PropTypes.func.isRequired,
};
const PaginationDropdown = ({ toggleClassName, ranges, value, setValue }) => (
<UncontrolledDropdown>
<DropdownToggle caret color="link" className={toggleClassName}>
Paginate
</DropdownToggle>
<DropdownMenu right>
{ranges.map((itemsPerPage) => (
<DropdownItem key={itemsPerPage} active={itemsPerPage === value} onClick={() => setValue(itemsPerPage)}>
<b>{itemsPerPage}</b> items per page
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem disabled={value === Infinity} onClick={() => setValue(Infinity)}>
<i>Clear pagination</i>
</DropdownItem>
</DropdownMenu>
</UncontrolledDropdown>
);
PaginationDropdown.propTypes = propTypes;
export default PaginationDropdown;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import searchIcon from '@fortawesome/fontawesome-free-solid/faSearch';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import './SearchField.scss';

View File

@@ -20,4 +20,5 @@
@include vertical-align();
right: 15px;
cursor: pointer;
}

View File

@@ -2,11 +2,10 @@ import React from 'react';
import { UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
import { toPairs } from 'ramda';
import PropTypes from 'prop-types';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import sortAscIcon from '@fortawesome/fontawesome-free-solid/faSortAmountUp';
import sortDescIcon from '@fortawesome/fontawesome-free-solid/faSortAmountDown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
import classNames from 'classnames';
import { determineOrderDir } from '../utils/utils';
import { determineOrderDir } from './utils';
import './SortingDropdown.scss';
const propTypes = {
@@ -34,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
<DropdownToggle
caret
color={isButton ? 'secondary' : 'link'}
className={classNames({ 'btn-block': isButton, 'btn-sm sorting-dropdown__paddingless': !isButton })}
className={classNames({ 'btn-block': isButton, 'btn-sm paddingless': !isButton })}
>
Order by
</DropdownToggle>

View File

@@ -10,7 +10,3 @@
margin: 3.5px 0 0;
float: right;
}
.sorting-dropdown__paddingless.sorting-dropdown__paddingless {
padding: 0;
}

View File

@@ -1,4 +1,3 @@
// Breakpoints
$xsMax: 575px;
$smMin: 576px;

View File

@@ -1,4 +0,0 @@
@mixin border-radius($radius) {
border-radius: $radius;
-webkit-border-radius: $radius;
}

View File

@@ -1,4 +0,0 @@
@mixin box-shadow($shadow) {
-webkit-box-shadow: $shadow;
box-shadow: $shadow;
}

View File

@@ -0,0 +1,8 @@
@mixin fit-with-margin($margin) {
$offset: $margin * 2;
width: calc(100% - #{$offset});
max-width: calc(100% - #{$offset});
height: calc(100% - #{$offset});
margin: $margin;
}

View File

@@ -1,15 +1,11 @@
import { range } from 'ramda';
import PropTypes from 'prop-types';
import { rangeOf } from '../utils';
const HEX_COLOR_LENGTH = 6;
const { floor, random } = Math;
const letters = '0123456789ABCDEF';
const buildRandomColor = () =>
`#${
range(0, HEX_COLOR_LENGTH)
.map(() => letters[floor(random() * letters.length)])
.join('')
}`;
`#${rangeOf(HEX_COLOR_LENGTH, () => letters[floor(random() * letters.length)]).join('')}`;
const normalizeKey = (key) => key.toLowerCase().trim();
export default class ColorGenerator {

View File

@@ -2,7 +2,6 @@ import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
const STATUS_UNAUTHORIZED = 401;
const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export default class ShlinkApiClient {
@@ -10,98 +9,54 @@ export default class ShlinkApiClient {
this.axios = axios;
this._baseUrl = buildRestUrl(baseUrl);
this._apiKey = apiKey || '';
this._token = '';
}
listShortUrls = (options = {}) =>
this._performRequest('/short-codes', 'GET', options)
.then((resp) => resp.data.shortUrls)
.catch((e) => this._handleAuthError(e, this.listShortUrls, [ options ]));
this._performRequest('/short-urls', 'GET', options)
.then((resp) => resp.data.shortUrls);
createShortUrl = (options) => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-codes', 'POST', {}, filteredOptions)
.then((resp) => resp.data)
.catch((e) => this._handleAuthError(e, this.createShortUrl, [ filteredOptions ]));
return this._performRequest('/short-urls', 'POST', {}, filteredOptions)
.then((resp) => resp.data);
};
getShortUrlVisits = (shortCode, dates) =>
this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates)
.then((resp) => resp.data.visits.data)
.catch((e) => this._handleAuthError(e, this.getShortUrlVisits, [ shortCode, dates ]));
getShortUrlVisits = (shortCode, query) =>
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
.then((resp) => resp.data.visits);
getShortUrl = (shortCode) =>
this._performRequest(`/short-codes/${shortCode}`, 'GET')
.then((resp) => resp.data)
.catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ]));
this._performRequest(`/short-urls/${shortCode}`, 'GET')
.then((resp) => resp.data);
deleteShortUrl = (shortCode) =>
this._performRequest(`/short-codes/${shortCode}`, 'DELETE')
.then(() => ({}))
.catch((e) => this._handleAuthError(e, this.deleteShortUrl, [ shortCode ]));
this._performRequest(`/short-urls/${shortCode}`, 'DELETE')
.then(() => ({}));
updateShortUrlTags = (shortCode, tags) =>
this._performRequest(`/short-codes/${shortCode}/tags`, 'PUT', {}, { tags })
.then((resp) => resp.data.tags)
.catch((e) => this._handleAuthError(e, this.updateShortUrlTags, [ shortCode, tags ]));
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags })
.then((resp) => resp.data.tags);
listTags = () =>
this._performRequest('/tags', 'GET')
.then((resp) => resp.data.tags.data)
.catch((e) => this._handleAuthError(e, this.listTags, []));
.then((resp) => resp.data.tags.data);
deleteTags = (tags) =>
this._performRequest('/tags', 'DELETE', { tags })
.then(() => ({ tags }))
.catch((e) => this._handleAuthError(e, this.deleteTags, [ tags ]));
.then(() => ({ tags }));
editTag = (oldName, newName) =>
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }))
.catch((e) => this._handleAuthError(e, this.editTag, [ oldName, newName ]));
.then(() => ({ oldName, newName }));
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
if (isEmpty(this._token)) {
this._token = await this._authenticate();
}
return await this.axios({
_performRequest = async (url, method = 'GET', query = {}, body = {}) =>
await this.axios({
method,
url: `${this._baseUrl}${url}`,
headers: { Authorization: `Bearer ${this._token}` },
headers: { 'X-Api-Key': this._apiKey },
params: query,
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
}).then((resp) => {
// Save new token
const { authorization = '' } = resp.headers;
this._token = authorization.substr('Bearer '.length);
return resp;
});
};
_authenticate = async () => {
const resp = await this.axios({
method: 'POST',
url: `${this._baseUrl}/authenticate`,
data: { apiKey: this._apiKey },
});
return resp.data.token;
};
_handleAuthError = (e, method, args) => {
// If auth failed, reset token to force it to be regenerated, and perform a new request
if (e.response.status === STATUS_UNAUTHORIZED) {
this._token = '';
return method(...args);
}
// Otherwise, let caller handle the rejection
return Promise.reject(e);
};
}

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { stateFlagTimeout } from '../utils';
import Storage from './Storage';
import ColorGenerator from './ColorGenerator';
import buildShlinkApiClient from './ShlinkApiClientBuilder';
@@ -10,6 +11,9 @@ const provideServices = (bottle) => {
bottle.constant('axios', axios);
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
bottle.constant('setTimeout', global.setTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
};
export default provideServices;

View File

@@ -1,6 +1,19 @@
const DEFAULT_TIMEOUT_DELAY = 2000;
import L from 'leaflet';
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda';
export const stateFlagTimeout = (setState, flagName, initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
const { ceil } = Math;
export const stateFlagTimeout = (setTimeout) => (
setState,
flagName,
initialValue = true,
delay = DEFAULT_TIMEOUT_DELAY
) => {
setState({ [flagName]: initialValue });
setTimeout(() => setState({ [flagName]: !initialValue }), delay);
};
@@ -17,3 +30,17 @@ export const determineOrderDir = (clickedField, currentOrderField, currentOrderD
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
};
export const fixLeafletIcons = () => {
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: marker2x,
iconUrl: marker,
shadowUrl: markerShadow,
});
};
export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn);
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;

View File

@@ -1,18 +1,16 @@
import { Card, CardHeader, CardBody } from 'reactstrap';
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import PropTypes from 'prop-types';
import React from 'react';
import { keys, values } from 'ramda';
import './GraphCard.scss';
const propTypes = {
title: PropTypes.string,
children: PropTypes.node,
title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]),
footer: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
isBarChart: PropTypes.bool,
stats: PropTypes.object,
matchMedia: PropTypes.func,
};
const defaultProps = {
matchMedia: global.window ? global.window.matchMedia : () => {},
max: PropTypes.number,
};
const generateGraphData = (title, isBarChart, labels, data) => ({
@@ -36,62 +34,43 @@ const generateGraphData = (title, isBarChart, labels, data) => ({
],
});
const determineGraphAspectRatio = (barsCount, isBarChart, matchMedia) => {
const determineAspectRationModifier = () => {
switch (true) {
case matchMedia('(max-width: 1200px)').matches:
return 1.5; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 992px)').matches:
return 1.75; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 768px)').matches:
return 2; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 576px)').matches:
return 2.25; // eslint-disable-line no-magic-numbers
default:
return 1;
}
};
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
const MAX_BARS_WITHOUT_HEIGHT = 20;
const DEFAULT_ASPECT_RATION = 2;
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
return shouldCalculateAspectRatio
? MAX_BARS_WITHOUT_HEIGHT / determineAspectRationModifier() * DEFAULT_ASPECT_RATION / barsCount
: DEFAULT_ASPECT_RATION;
};
const renderGraph = (title, isBarChart, stats, matchMedia) => {
const renderGraph = (title, isBarChart, stats, max) => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const labels = keys(stats);
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(stats);
const aspectRatio = determineGraphAspectRatio(labels.length, isBarChart, matchMedia);
const options = {
aspectRatio,
legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart ? {
scales: isBarChart && {
xAxes: [
{
ticks: { beginAtZero: true },
ticks: { beginAtZero: true, max },
},
],
} : null,
},
tooltips: {
intersect: !isBarChart,
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
},
};
const graphData = generateGraphData(title, isBarChart, labels, data);
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null;
return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={null} />;
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
return <Component key={height} data={graphData} options={options} height={height} />;
};
const GraphCard = ({ title, children, isBarChart, stats, matchMedia }) => (
const GraphCard = ({ title, footer, isBarChart, stats, max }) => (
<Card className="mt-4">
<CardHeader className="graph-card__header">{children || title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max)}</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card>
);
GraphCard.propTypes = propTypes;
GraphCard.defaultProps = defaultProps;
export default GraphCard;

View File

@@ -0,0 +1,4 @@
.graph-card__footer--sticky {
position: sticky;
bottom: 0;
}

View File

@@ -1,6 +1,6 @@
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed } from 'ramda';
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
@@ -8,17 +8,15 @@ import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage';
import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import { VisitsHeader } from './VisitsHeader';
import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
const ShortUrlVisits = ({
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
}) => class ShortUrlVisits extends React.Component {
const ShortUrlVisits = (
{ processStatsFromVisits },
OpenMapModalBtn
) => class ShortUrlVisits extends React.PureComponent {
static propTypes = {
match: PropTypes.shape({
params: PropTypes.object,
@@ -27,33 +25,47 @@ const ShortUrlVisits = ({
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func,
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props;
getShortUrlVisits(params.shortCode, mapObjIndexed(
const { shortCode } = params;
const dates = mapObjIndexed(
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state
));
);
const { startDate, endDate } = dates;
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
getShortUrlVisits(shortCode, dates);
};
componentDidMount() {
const { match: { params }, getShortUrlDetail } = this.props;
const { shortCode } = params;
this.timeWhenMounted = new Date().getTime();
this.loadVisits();
getShortUrlDetail(params.shortCode);
getShortUrlDetail(shortCode);
}
componentWillUnmount() {
this.props.cancelGetShortUrlVisits();
}
render() {
const { shortUrlVisits, shortUrlDetail } = this.props;
const renderVisitsContent = () => {
const { visits, loading, error } = shortUrlVisits;
const { visits, loading, loadingLarge, error } = shortUrlVisits;
if (loading) {
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> {message}</MutedMessage>;
}
if (error) {
@@ -68,17 +80,33 @@ const ShortUrlVisits = ({
return <MutedMessage>There are no visits matching current filter :(</MutedMessage>;
}
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
{ id: this.memoizationId, visits }
);
const mapLocations = values(citiesForMap);
return (
<div className="row">
<div className="col-md-6">
<GraphCard title="Operating systems" stats={processOsStats(visits)} />
<div className="col-xl-4 col-lg-6">
<GraphCard title="Operating systems" stats={os} />
</div>
<div className="col-md-6">
<GraphCard title="Browsers" stats={processBrowserStats(visits)} />
<div className="col-xl-4 col-lg-6">
<GraphCard title="Browsers" stats={browsers} />
</div>
<div className="col-md-6">
<div className="col-xl-4">
<SortableBarGraph
stats={processCountriesStats(visits)}
stats={referrers}
withPagination={false}
title="Referrers"
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
/>
</div>
<div className="col-lg-6">
<SortableBarGraph
stats={countries}
title="Countries"
sortingItems={{
name: 'Country name',
@@ -86,12 +114,16 @@ const ShortUrlVisits = ({
}}
/>
</div>
<div className="col-md-6">
<div className="col-lg-6">
<SortableBarGraph
stats={processReferrersStats(visits)}
title="Referrers"
stats={cities}
title="Cities"
extraHeaderContent={(activeCities) =>
mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
}
sortingItems={{
name: 'Referrer name',
name: 'City name',
amount: 'Visits amount',
}}
/>
@@ -112,7 +144,7 @@ const ShortUrlVisits = ({
placeholderText="Since"
isClearable
maxDate={this.state.endDate}
onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())}
onChange={(date) => this.setState({ startDate: date }, this.loadVisits)}
/>
</div>
<div className="col-xl-3 col-lg-4 col-md-6">
@@ -122,7 +154,7 @@ const ShortUrlVisits = ({
placeholderText="Until"
isClearable
minDate={this.state.startDate}
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
onChange={(date) => this.setState({ endDate: date }, this.loadVisits)}
/>
</div>
</div>

View File

@@ -1,54 +1,124 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fromPairs, head, identity, keys, pipe, prop, reverse, sortBy, toLower, toPairs, type } from 'ramda';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import SortingDropdown from '../utils/SortingDropdown';
import PaginationDropdown from '../utils/PaginationDropdown';
import { rangeOf, roundTen } from '../utils/utils';
import GraphCard from './GraphCard';
const { max } = Math;
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component {
static propTypes = {
stats: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool,
};
state = {
orderField: undefined,
orderDir: undefined,
currentPage: 1,
itemsPerPage: Infinity,
};
render() {
const { stats, sortingItems, title } = this.props;
const sortStats = () => {
if (!this.state.orderField) {
return stats;
}
determineStats(stats, sortingItems) {
const pairs = toPairs(stats);
const sortedPairs = !this.state.orderField ? pairs : sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
pairs
);
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : identity(value);
const sortedPairs = sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
toPairs(stats)
);
if (directionalPairs.length <= this.state.itemsPerPage) {
return { currentPageStats: fromPairs(directionalPairs) };
}
return fromPairs(this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs));
const pages = splitEvery(this.state.itemsPerPage, directionalPairs);
return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
pagination: this.renderPagination(pages.length),
max: roundTen(max(...directionalPairs.map(pickValueFromPair))),
};
}
determineCurrentPagePairs(pages) {
const page = pages[this.state.currentPage - 1];
if (this.state.currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
}
renderPagination(pagesCount) {
const { currentPage } = this.state;
return (
<GraphCard stats={sortStats()} isBarChart>
<Pagination listClassName="flex-wrap mb-0">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink previous tag="span" onClick={() => this.setState({ currentPage: currentPage - 1 })} />
</PaginationItem>
{rangeOf(pagesCount, (page) => (
<PaginationItem key={page} active={page === currentPage}>
<PaginationLink tag="span" onClick={() => this.setState({ currentPage: page })}>{page}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag="span" onClick={() => this.setState({ currentPage: currentPage + 1 })} />
</PaginationItem>
</Pagination>
);
}
render() {
const { stats, sortingItems, title, extraHeaderContent, withPagination = true } = this.props;
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
const activeCities = keys(currentPageStats);
const computeTitle = () => (
<React.Fragment>
{title}
<div className="float-right">
<SortingDropdown
isButton={false}
right
items={sortingItems}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
items={sortingItems}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir })}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir, currentPage: 1 })}
/>
</div>
</GraphCard>
{withPagination && keys(stats).length > 50 && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm paddingless mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={this.state.itemsPerPage}
setValue={(itemsPerPage) => this.setState({ itemsPerPage, currentPage: 1 })}
/>
</div>
)}
{extraHeaderContent && (
<div className="float-right">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</React.Fragment>
);
return <GraphCard isBarChart title={computeTitle} stats={currentPageStats} footer={pagination} max={max} />;
}
}

View File

@@ -11,7 +11,7 @@ const propTypes = {
shortUrlVisits: shortUrlVisitsType.isRequired,
};
export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
@@ -34,13 +34,7 @@ export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
Visit stats for <ExternalLink href={shortLink} />
</h2>
<hr />
{shortUrl.dateCreated && (
<div>
Created:
&nbsp;
{renderDate()}
</div>
)}
<div>Created: {renderDate()}</div>
<div>
Long URL:
&nbsp;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Modal, ModalBody } from 'reactstrap';
import { Map, TileLayer, Marker, Popup } from 'react-leaflet';
import { prop } from 'ramda';
import * as PropTypes from 'prop-types';
import './MapModal.scss';
const propTypes = {
toggle: PropTypes.func,
isOpen: PropTypes.bool,
title: PropTypes.string,
locations: PropTypes.arrayOf(PropTypes.shape({
cityName: PropTypes.string.isRequired,
latLong: PropTypes.arrayOf(PropTypes.number).isRequired,
count: PropTypes.number.isRequired,
})),
};
const defaultProps = {
locations: [],
};
const OpenStreetMapTile = () => (
<TileLayer
attribution='&amp;copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
);
const calculateMapProps = (locations) => {
if (locations.length === 0) {
return {};
}
if (locations.length > 1) {
return { bounds: locations.map(prop('latLong')) };
}
// When there's only one location, an error is thrown if trying to calculate the bounds.
// When that happens, we use zoom and center as a workaround
const [{ latLong: center }] = locations;
return { zoom: '10', center };
};
const MapModal = ({ toggle, isOpen, title, locations }) => (
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
<ModalBody className="map-modal__modal-body">
<h3 className="map-modal__modal-title">
{title}
<button type="button" className="close" onClick={toggle}>&times;</button>
</h3>
<Map {...calculateMapProps(locations)}>
<OpenStreetMapTile />
{locations.map(({ cityName, latLong, count }, index) => (
<Marker key={index} position={latLong}>
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{cityName}</b></Popup>
</Marker>
))}
</Map>
</ModalBody>
</Modal>
);
MapModal.propTypes = propTypes;
MapModal.defaultProps = defaultProps;
export default MapModal;

View File

@@ -0,0 +1,45 @@
@import '../../utils/base';
@import '../../utils/mixins/fit-with-margin';
.map-modal__modal {
@media (min-width: $mdMin) {
$margin: 20px;
@include fit-with-margin($margin);
}
@media (max-width: $smMax) {
$margin: 10px;
@include fit-with-margin($margin);
}
}
.map-modal__modal-content {
height: 100%;
}
.map-modal__modal-title {
position: absolute;
width: 100%;
z-index: 1001;
padding: .5rem 1rem 1rem;
margin: 0;
color: #fff;
background: linear-gradient(rgba(0, 0, 0, .5), rgba(0, 0, 0, 0));
}
.map-modal__modal-body {
padding: 0;
display: flex;
overflow: hidden;
}
.map-modal__modal .leaflet-container {
flex: 1 1 auto;
border-radius: .3rem;
}
.map-modal__modal .leaflet-top .leaflet-control {
margin-top: 60px;
}

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
import * as PropTypes from 'prop-types';
import './OpenMapModalBtn.scss';
const propTypes = {
modalTitle: PropTypes.string.isRequired,
locations: PropTypes.arrayOf(PropTypes.object),
activeCities: PropTypes.arrayOf(PropTypes.string),
};
const OpenMapModalBtn = (MapModal) => {
const OpenMapModalBtn = ({ modalTitle, locations = [], activeCities }) => {
const [ mapIsOpened, setMapIsOpened ] = useState(false);
const [ dropdownIsOpened, setDropdownIsOpened ] = useState(false);
const [ locationsToShow, setLocationsToShow ] = useState([]);
const buttonRef = React.createRef();
const filterLocations = (locations) => locations.filter(({ cityName }) => activeCities.includes(cityName));
const toggleMap = () => setMapIsOpened(!mapIsOpened);
const onClick = () => {
if (!activeCities) {
setLocationsToShow(locations);
setMapIsOpened(true);
return;
}
setDropdownIsOpened(true);
};
const openMapWithLocations = (filtered) => () => {
setLocationsToShow(filtered ? filterLocations(locations) : locations);
setMapIsOpened(true);
};
return (
<React.Fragment>
<button className="btn btn-link open-map-modal-btn__btn" ref={buttonRef} onClick={onClick}>
<FontAwesomeIcon icon={mapIcon} />
</button>
<UncontrolledTooltip placement="left" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
<Dropdown isOpen={dropdownIsOpened} toggle={() => setDropdownIsOpened(!dropdownIsOpened)} inNavbar>
<DropdownMenu right>
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
</DropdownMenu>
</Dropdown>
<MapModal toggle={toggleMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</React.Fragment>
);
};
OpenMapModalBtn.propTypes = propTypes;
return OpenMapModalBtn;
};
export default OpenMapModalBtn;

View File

@@ -0,0 +1,4 @@
.open-map-modal-btn__btn {
padding: 0;
margin-right: 1rem;
}

View File

@@ -1,9 +1,12 @@
import PropTypes from 'prop-types';
import { flatten, prop, range, splitEvery } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlVisitsType = PropTypes.shape({
@@ -15,7 +18,9 @@ export const shortUrlVisitsType = PropTypes.shape({
const initialState = {
visits: [],
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
};
export default function reducer(state = initialState, action) {
@@ -24,18 +29,34 @@ export default function reducer(state = initialState, action) {
return {
...state,
loading: true,
loadingLarge: false,
cancelLoad: false,
};
case GET_SHORT_URL_VISITS_ERROR:
return {
...state,
loading: false,
loadingLarge: false,
error: true,
cancelLoad: false,
};
case GET_SHORT_URL_VISITS:
return {
visits: action.visits,
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
};
case GET_SHORT_URL_VISITS_LARGE:
return {
...state,
loadingLarge: true,
};
case GET_SHORT_URL_VISITS_CANCEL:
return {
...state,
cancelLoad: true,
};
default:
return state;
@@ -46,13 +67,61 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
dispatch({ type: GET_SHORT_URL_VISITS_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { getShortUrlVisits } = buildShlinkApiClient(selectedServer);
const itemsPerPage = 5000;
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
const loadVisits = async (page = 1) => {
const { pagination, data } = await getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage });
// If pagination was not returned, then this is an older shlink version. Just return data
if (!pagination || isLastPage(pagination)) {
return data;
}
// If there are more pages, make requests in blocks of 4
const parallelRequestsCount = 4;
const parallelStartingPage = 2;
const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1);
const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange);
if (pagination.pagesCount - 1 > parallelRequestsCount) {
dispatch({ type: GET_SHORT_URL_VISITS_LARGE });
}
return data.concat(await loadPagesBlocks(pagesBlocks));
};
const loadPagesBlocks = async (pagesBlocks, index = 0) => {
const { shortUrlVisits: { cancelLoad } } = getState();
if (cancelLoad) {
return [];
}
const data = await loadVisitsInParallel(pagesBlocks[index]);
if (index < pagesBlocks.length - 1) {
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
}
return data;
};
const loadVisitsInParallel = (pages) =>
Promise.all(pages.map(
(page) =>
getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage })
.then(prop('data'))
)).then(flatten);
try {
const visits = await shlinkApiClient.getShortUrlVisits(shortCode, dates);
const visits = await loadVisits();
dispatch({ visits, type: GET_SHORT_URL_VISITS });
} catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
}
};
export const cancelGetShortUrlVisits = () => ({ type: GET_SHORT_URL_VISITS_CANCEL });

View File

@@ -1,4 +1,4 @@
import { assoc, isNil, isEmpty, reduce } from 'ramda';
import { isNil, isEmpty, memoizeWith, prop } from 'ramda';
const osFromUserAgent = (userAgent) => {
const lowerUserAgent = userAgent.toLowerCase();
@@ -42,50 +42,69 @@ const extractDomain = (url) => {
return domain.split(':')[0];
};
export const processOsStats = (visits) =>
reduce(
(stats, { userAgent }) => {
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
const visitLocationHasProperty = (visitLocation, propertyName) =>
!isNil(visitLocation)
&& !isNil(visitLocation[propertyName])
&& !isEmpty(visitLocation[propertyName]);
return assoc(os, (stats[os] || 0) + 1, stats);
},
{},
visits,
);
const updateOsStatsForVisit = (osStats, { userAgent }) => {
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
export const processBrowserStats = (visits) =>
reduce(
(stats, { userAgent }) => {
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
osStats[os] = (osStats[os] || 0) + 1;
};
return assoc(browser, (stats[browser] || 0) + 1, stats);
},
{},
visits,
);
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
export const processReferrersStats = (visits) =>
reduce(
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
};
const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
const notHasDomain = isNil(referer) || isEmpty(referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(referer);
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
};
const updateLocationsStatsForVisit = (propertyName) => (stats, { visitLocation }) => {
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName);
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown';
stats[value] = (stats[value] || 0) + 1;
};
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('countryName');
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('cityName');
const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => {
if (!visitLocationHasProperty(visitLocation, 'cityName')) {
return;
}
const { cityName, latitude, longitude } = visitLocation;
const currentCity = citiesForMapStats[cityName] || {
cityName,
count: 0,
latLong: [ parseFloat(latitude), parseFloat(longitude) ],
};
currentCity.count++;
citiesForMapStats[cityName] = currentCity;
};
export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
visits.reduce(
(stats, visit) => {
const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer);
// We mutate the original object because it has a big side effect when large data sets are processed
updateOsStatsForVisit(stats.os, visit);
updateBrowsersStatsForVisit(stats.browsers, visit);
updateReferrersStatsForVisit(stats.referrers, visit);
updateCountriesStatsForVisit(stats.countries, visit);
updateCitiesStatsForVisit(stats.cities, visit);
updateCitiesForMapForVisit(stats.citiesForMap, visit);
return assoc(domain, (stats[domain] || 0) + 1, stats);
return stats;
},
{},
visits,
);
export const processCountriesStats = (visits) =>
reduce(
(stats, { visitLocation }) => {
const notHasCountry = isNil(visitLocation)
|| isNil(visitLocation.countryName)
|| isEmpty(visitLocation.countryName);
const country = notHasCountry ? 'Unknown' : visitLocation.countryName;
return assoc(country, (stats[country] || 0) + 1, stats);
},
{},
visits,
);
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
));

View File

@@ -1,14 +1,18 @@
import ShortUrlVisits from '../ShortUrlVisits';
import { getShortUrlVisits } from '../reducers/shortUrlVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
import MapModal from '../helpers/MapModal';
import * as visitsParser from './VisitsParser';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser');
bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal');
bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail' ],
[ 'getShortUrlVisits', 'getShortUrlDetail' ]
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ]
));
// Services
@@ -17,6 +21,7 @@ const provideServices = (bottle, connect) => {
// Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
};
export default provideServices;

View File

@@ -19,12 +19,17 @@ describe('<App />', () => {
it('renders app main routes', () => {
const routes = wrapper.find(Route);
const expectedRoutesCount = 3;
const second = 2;
const expectedRoutesCount = 4;
const expectedPaths = [
'/server/create',
'/',
'/server/:serverId',
];
expect.assertions(expectedPaths.length + 1);
expect(routes).toHaveLength(expectedRoutesCount);
expect(routes.at(0).prop('path')).toEqual('/server/create');
expect(routes.at(1).prop('path')).toEqual('/');
expect(routes.at(second).prop('path')).toEqual('/server/:serverId');
expectedPaths.forEach((path, index) => {
expect(routes.at(index).prop('path')).toEqual(path);
});
});
});

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Button } from 'reactstrap';
import createErrorHandler from '../../src/common/ErrorHandler';
describe('<ErrorHandler />', () => {
const window = {
location: {
reload: jest.fn(),
},
};
const console = { error: jest.fn() };
let wrapper;
beforeEach(() => {
const ErrorHandler = createErrorHandler(window, console);
wrapper = shallow(<ErrorHandler children={<span>Foo</span>} />);
});
afterEach(() => wrapper.unmount());
it('renders children when no error has occurred', () => {
expect(wrapper.text()).toEqual('Foo');
expect(wrapper.find(Button)).toHaveLength(0);
});
it('renders error page when error has occurred', () => {
wrapper.setState({ hasError: true });
expect(wrapper.text()).toContain('Oops! This is awkward :S');
expect(wrapper.text()).toContain(
'It seems that something went wrong. Try refreshing the page or just click this button.'
);
expect(wrapper.find(Button)).toHaveLength(1);
});
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Link } from 'react-router-dom';
import NotFound from '../../src/common/NotFound';
describe('<NotFound />', () => {
let wrapper;
const createWrapper = (props = {}) => {
wrapper = shallow(<NotFound {...props} />);
const content = wrapper.text();
return { wrapper, content };
};
afterEach(() => wrapper && wrapper.unmount());
it('shows expected error title', () => {
const { content } = createWrapper();
expect(content).toContain('Oops! We could not find requested route.');
});
it('shows expected error message', () => {
const { content } = createWrapper();
expect(content).toContain(
'Use your browser\'s back button to navigate to the page you have previously come from, or just press this button.'
);
});
it('shows a link to the home', () => {
const { wrapper } = createWrapper();
const link = wrapper.find(Link);
expect(link.prop('to')).toEqual('/');
expect(link.prop('className')).toEqual('btn btn-outline-primary btn-lg');
expect(link.prop('children')).toEqual('Home');
});
it('shows a link with provided props', () => {
const { wrapper } = createWrapper({ to: '/foo/bar', btnText: 'Hello' });
const link = wrapper.find(Link);
expect(link.prop('to')).toEqual('/foo/bar');
expect(link.prop('className')).toEqual('btn btn-outline-primary btn-lg');
expect(link.prop('children')).toEqual('Hello');
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import createScrollToTop from '../../src/common/ScrollToTop';
describe('<ScrollToTop />', () => {
let wrapper;
const window = {
scrollTo: sinon.spy(),
};
beforeEach(() => {
const ScrollToTop = createScrollToTop(window);
wrapper = shallow(<ScrollToTop locaction={{ href: 'foo' }}>Foobar</ScrollToTop>);
});
afterEach(() => {
wrapper.unmount();
window.scrollTo.resetHistory();
});
it('just renders children', () => expect(wrapper.text()).toEqual('Foobar'));
it('scrolls to top when location changes', () => {
wrapper.instance().componentDidUpdate({ location: { href: 'bar' } });
expect(window.scrollTo.calledOnce).toEqual(true);
});
});

View File

@@ -13,6 +13,7 @@ describe('serverReducer', () => {
abc123: { id: 'abc123' },
def456: { id: 'def456' },
};
const expectedFetchServersResult = { type: FETCH_SERVERS, servers };
const ServersServiceMock = {
listServers: sinon.fake.returns(servers),
createServer: sinon.fake(),
@@ -40,38 +41,38 @@ describe('serverReducer', () => {
it('fetches servers and returns them as part of the action', () => {
const result = listServers(ServersServiceMock)();
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);
expect(ServersServiceMock.createServer.callCount).toEqual(0);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0);
expect(ServersServiceMock.createServers.callCount).toEqual(0);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.calledOnce).toEqual(true);
expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.deleteServer.called).toEqual(false);
expect(ServersServiceMock.createServers.called).toEqual(false);
});
});
describe('createServer', () => {
it('adds new server and then fetches servers again', () => {
const serverToCreate = { id: 'abc123' };
const result = createServer(ServersServiceMock)(serverToCreate);
const result = createServer(ServersServiceMock, () => expectedFetchServersResult)(serverToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);
expect(ServersServiceMock.createServer.callCount).toEqual(1);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.createServer.calledOnce).toEqual(true);
expect(ServersServiceMock.createServer.firstCall.calledWith(serverToCreate)).toEqual(true);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0);
expect(ServersServiceMock.createServers.callCount).toEqual(0);
expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.deleteServer.called).toEqual(false);
expect(ServersServiceMock.createServers.called).toEqual(false);
});
});
describe('deleteServer', () => {
it('deletes a server and then fetches servers again', () => {
const serverToDelete = { id: 'abc123' };
const result = deleteServer(ServersServiceMock)(serverToDelete);
const result = deleteServer(ServersServiceMock, () => expectedFetchServersResult)(serverToDelete);
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);
expect(ServersServiceMock.createServer.callCount).toEqual(0);
expect(ServersServiceMock.createServers.callCount).toEqual(0);
expect(ServersServiceMock.deleteServer.callCount).toEqual(1);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.createServers.called).toEqual(false);
expect(ServersServiceMock.deleteServer.calledOnce).toEqual(true);
expect(ServersServiceMock.deleteServer.firstCall.calledWith(serverToDelete)).toEqual(true);
});
});
@@ -79,14 +80,14 @@ describe('serverReducer', () => {
describe('createServer', () => {
it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(servers);
const result = createServers(ServersServiceMock)(serversToCreate);
const result = createServers(ServersServiceMock, () => expectedFetchServersResult)(serversToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);
expect(ServersServiceMock.createServer.callCount).toEqual(0);
expect(ServersServiceMock.createServers.callCount).toEqual(1);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.createServers.calledOnce).toEqual(true);
expect(ServersServiceMock.createServers.firstCall.calledWith(serversToCreate)).toEqual(true);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0);
expect(ServersServiceMock.deleteServer.called).toEqual(false);
});
});
});

View File

@@ -15,7 +15,7 @@ describe('<CreateShortUrl />', () => {
const createShortUrl = sinon.spy();
beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector);
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '');
wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />

View File

@@ -3,17 +3,24 @@ import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap';
import CreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
import * as sinon from 'sinon';
import createCreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
describe('<CreateShortUrlResult />', () => {
let wrapper;
const stateFlagTimeout = sinon.spy();
const createWrapper = (result, error = false) => {
const CreateShortUrlResult = createCreateShortUrlResult(stateFlagTimeout);
wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
afterEach(() => {
stateFlagTimeout.resetHistory();
wrapper && wrapper.unmount();
});
it('renders an error when error is true', () => {
const wrapper = createWrapper({}, true);
@@ -37,12 +44,12 @@ describe('<CreateShortUrlResult />', () => {
expect(wrapper.find(Tooltip)).toHaveLength(1);
});
it('Shows tooltip when copy to clipboard button is clicked', () => {
it('Invokes tooltip timeout when copy to clipboard button is clicked', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
const copyBtn = wrapper.find(CopyToClipboard);
expect(wrapper.state('showCopyTooltip')).toEqual(false);
expect(stateFlagTimeout.callCount).toEqual(0);
copyBtn.simulate('copy');
expect(wrapper.state('showCopyTooltip')).toEqual(true);
expect(stateFlagTimeout.callCount).toEqual(1);
});
});

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { shallow } from 'enzyme';
import moment from 'moment';
import Moment from 'react-moment';
import { assoc, toString } from 'ramda';
import * as sinon from 'sinon';
import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
import ExternalLink from '../../../src/utils/ExternalLink';
import Tag from '../../../src/tags/helpers/Tag';
describe('<ShortUrlsRow />', () => {
let wrapper;
const mockFunction = () => '';
const ShortUrlsRowMenu = mockFunction;
const stateFlagTimeout = sinon.spy();
const colorGenerator = {
getColorForKey: mockFunction,
setColorForKey: mockFunction,
};
const server = {
url: 'https://doma.in',
};
const shortUrl = {
shortCode: 'abc123',
shortUrl: 'http://doma.in/abc123',
longUrl: 'http://foo.com/bar',
dateCreated: moment('2018-05-23 18:30:41').format(),
tags: [ 'nodejs', 'reactjs' ],
visitsCount: 45,
};
beforeEach(() => {
const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, stateFlagTimeout);
wrapper = shallow(
<ShortUrlsRow shortUrlsListParams={{}} refreshList={mockFunction} selecrtedServer={server} shortUrl={shortUrl} />
);
});
afterEach(() => wrapper.unmount());
it('renders date in first column', () => {
const col = wrapper.find('td').first();
const moment = col.find(Moment);
expect(moment.html()).toContain('>2018-05-23 18:30</time>');
});
it('renders short URL in second row', () => {
const col = wrapper.find('td').at(1);
const link = col.find(ExternalLink);
expect(link.prop('href')).toEqual(shortUrl.shortUrl);
});
it('renders long URL in third row', () => {
const col = wrapper.find('td').at(2);
const link = col.find(ExternalLink);
expect(link.prop('href')).toEqual(shortUrl.longUrl);
});
describe('renders list of tags in fourth row', () => {
it('with tags', () => {
const col = wrapper.find('td').at(3);
const tags = col.find(Tag);
expect(tags).toHaveLength(shortUrl.tags.length);
shortUrl.tags.forEach((tagValue, index) => {
const tag = tags.at(index);
expect(tag.prop('text')).toEqual(tagValue);
});
});
it('without tags', () => {
wrapper.setProps({ shortUrl: assoc('tags', [], shortUrl) });
const col = wrapper.find('td').at(3);
expect(col.text()).toContain('No tags');
});
});
it('renders visits count in fifth row', () => {
const col = wrapper.find('td').at(4);
expect(col.text()).toEqual(toString(shortUrl.visitsCount));
});
it('updates state when copied to clipboard', () => {
const col = wrapper.find('td').at(5);
const menu = col.find(ShortUrlsRowMenu);
expect(menu).toHaveLength(1);
expect(stateFlagTimeout.called).toEqual(false);
menu.simulate('copyToClipboard');
expect(stateFlagTimeout.calledOnce).toEqual(true);
});
it('shows copy hint when state prop is true', () => {
const isHidden = () => wrapper.find('td').at(5).find('.short-urls-row__copy-hint').prop('hidden');
expect(isHidden()).toEqual(true);
wrapper.setState({ copiedToClipboard: true });
expect(isHidden()).toEqual(false);
});
});

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import { ButtonDropdown, DropdownItem } from 'reactstrap';
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
describe('<ShortUrlsRowMenu />', () => {
let wrapper;
const DeleteShortUrlModal = () => '';
const EditTagsModal = () => '';
const onCopyToClipboard = sinon.spy();
const selectedServer = { id: 'abc123' };
const shortUrl = {
shortCode: 'abc123',
shortUrl: 'https://doma.in/abc123',
};
beforeEach(() => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal);
wrapper = shallow(
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} onCopyToClipboard={onCopyToClipboard} />
);
});
afterEach(() => wrapper.unmount());
it('renders modal windows', () => {
const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal);
const editTagsModal = wrapper.find(EditTagsModal);
const previewModal = wrapper.find(PreviewModal);
const qrCodeModal = wrapper.find(QrCodeModal);
expect(deleteShortUrlModal).toHaveLength(1);
expect(editTagsModal).toHaveLength(1);
expect(previewModal).toHaveLength(1);
expect(qrCodeModal).toHaveLength(1);
});
it('renders correct amount of menu items', () => {
const items = wrapper.find(DropdownItem);
const expectedNonDividerItems = 6;
const expectedDividerItems = 2;
expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems);
expect(items.find('[divider]')).toHaveLength(expectedDividerItems);
});
describe('toggles state when toggling modal windows', () => {
const assert = (modalComponent, stateProp, done) => {
const modal = wrapper.find(modalComponent);
expect(wrapper.state(stateProp)).toEqual(false);
modal.prop('toggle')();
setImmediate(() => {
expect(wrapper.state(stateProp)).toEqual(true);
done();
});
};
it('DeleteShortUrlModal', (done) => assert(DeleteShortUrlModal, 'isDeleteModalOpen', done));
it('EditTagsModal', (done) => assert(EditTagsModal, 'isTagsModalOpen', done));
it('PreviewModal', (done) => assert(PreviewModal, 'isPreviewModalOpen', done));
it('QrCodeModal', (done) => assert(QrCodeModal, 'isQrModalOpen', done));
});
it('toggles dropdown state when toggling dropdown', (done) => {
const dropdown = wrapper.find(ButtonDropdown);
expect(wrapper.state('isOpen')).toEqual(false);
dropdown.prop('toggle')();
setImmediate(() => {
expect(wrapper.state('isOpen')).toEqual(true);
done();
});
});
});

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity, range } from 'ramda';
import { identity } from 'ramda';
import * as sinon from 'sinon';
import createTagsList from '../../src/tags/TagsList';
import MuttedMessage from '../../src/utils/MuttedMessage';
import SearchField from '../../src/utils/SearchField';
import { rangeOf } from '../../src/utils/utils';
describe('<TagsList />', () => {
let wrapper;
@@ -53,7 +54,7 @@ describe('<TagsList />', () => {
it('renders the proper amount of groups and cards based on the amount of tags', () => {
const amountOfTags = 10;
const amountOfGroups = 4;
const wrapper = createWrapper({ filteredTags: range(0, amountOfTags).map((i) => `tag_${i}`) });
const wrapper = createWrapper({ filteredTags: rangeOf(amountOfTags, (i) => `tag_${i}`) });
const cards = wrapper.find(TagCard);
const groups = wrapper.find('.col-md-6');

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import { Modal, ModalBody, ModalFooter } from 'reactstrap';
import DeleteTagConfirmModal from '../../../src/tags/helpers/DeleteTagConfirmModal';
describe('<DeleteTagConfirmModal />', () => {
let wrapper;
const tag = 'nodejs';
const deleteTag = sinon.spy();
const tagDeleted = sinon.spy();
const createWrapper = (tagDelete) => {
wrapper = shallow(
<DeleteTagConfirmModal
tag={tag}
toggle={() => ''}
isOpen
deleteTag={deleteTag}
tagDeleted={tagDeleted}
tagDelete={tagDelete}
/>
);
return wrapper;
};
afterEach(() => {
wrapper && wrapper.unmount();
deleteTag.resetHistory();
tagDeleted.resetHistory();
});
it('asks confirmation for provided tag to be deleted', () => {
wrapper = createWrapper({ error: false, deleting: false });
const body = wrapper.find(ModalBody);
const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger');
expect(body.html()).toContain(`Are you sure you want to delete tag <b>${tag}</b>?`);
expect(delBtn.prop('disabled')).toEqual(false);
expect(delBtn.text()).toEqual('Delete tag');
});
it('shows error message when deletion failed', () => {
wrapper = createWrapper({ error: true, deleting: false });
const body = wrapper.find(ModalBody);
expect(body.html()).toContain('Something went wrong while deleting the tag :(');
});
it('shows loading status while deleting', () => {
wrapper = createWrapper({ error: false, deleting: true });
const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger');
expect(delBtn.prop('disabled')).toEqual(true);
expect(delBtn.text()).toEqual('Deleting tag...');
});
it('deletes tag modal when btn is clicked', () => {
wrapper = createWrapper({ error: false, deleting: true });
const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger');
delBtn.simulate('click');
expect(deleteTag.calledOnce).toEqual(true);
expect(deleteTag.calledWith(tag)).toEqual(true);
});
it('does no further actions when modal is closed without deleting tag', () => {
wrapper = createWrapper({ error: false, deleting: false });
const modal = wrapper.find(Modal);
modal.simulate('closed');
expect(tagDeleted.called).toEqual(false);
});
it('notifies tag to be deleted when modal is closed after deleting tag', () => {
wrapper = createWrapper({ error: false, deleting: false });
const modal = wrapper.find(Modal);
wrapper.instance().tagWasDeleted = true;
modal.simulate('closed');
expect(tagDeleted.calledOnce).toEqual(true);
expect(tagDeleted.calledWith(tag)).toEqual(true);
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import moment from 'moment';
import DateInput from '../../src/utils/DateInput';

View File

@@ -2,8 +2,8 @@ import React from 'react';
import { shallow } from 'enzyme';
import { DropdownItem } from 'reactstrap';
import { identity, values } from 'ramda';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faSortAmountDown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons';
import * as sinon from 'sinon';
import SortingDropdown from '../../src/utils/SortingDropdown';

View File

@@ -3,26 +3,20 @@ import { head, last } from 'ramda';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClient', () => {
const createAxiosMock = (extraData) => () =>
Promise.resolve({
headers: { authorization: 'Bearer abc123' },
data: { token: 'abc123' },
...extraData,
});
const createApiClient = (extraData) =>
new ShlinkApiClient(createAxiosMock(extraData));
const createAxiosMock = (data) => () => Promise.resolve(data);
const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data));
describe('listShortUrls', () => {
it('properly returns short URLs list', async () => {
const expectedList = [ 'foo', 'bar' ];
const apiClient = createApiClient({
const { listShortUrls } = createApiClient({
data: {
shortUrls: expectedList,
},
});
const actualList = await apiClient.listShortUrls();
const actualList = await listShortUrls();
expect(expectedList).toEqual(actualList);
});
@@ -34,17 +28,17 @@ describe('ShlinkApiClient', () => {
};
it('returns create short URL', async () => {
const apiClient = createApiClient({ data: shortUrl });
const result = await apiClient.createShortUrl({});
const { createShortUrl } = createApiClient({ data: shortUrl });
const result = await createShortUrl({});
expect(result).toEqual(shortUrl);
});
it('removes all empty options', async () => {
const axiosSpy = sinon.spy(createAxiosMock({ data: shortUrl }));
const apiClient = new ShlinkApiClient(axiosSpy);
const { createShortUrl } = new ShlinkApiClient(axiosSpy);
await apiClient.createShortUrl(
await createShortUrl(
{ foo: 'bar', empty: undefined, anotherEmpty: null }
);
const lastAxiosCall = last(axiosSpy.getCalls());
@@ -64,14 +58,14 @@ describe('ShlinkApiClient', () => {
},
},
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const { getShortUrlVisits } = new ShlinkApiClient(axiosSpy);
const actualVisits = await apiClient.getShortUrlVisits('abc123', {});
const actualVisits = await getShortUrlVisits('abc123', {});
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(expectedVisits).toEqual(actualVisits);
expect(axiosArgs.url).toContain('/short-codes/abc123/visits');
expect({ data: expectedVisits }).toEqual(actualVisits);
expect(axiosArgs.url).toContain('/short-urls/abc123/visits');
expect(axiosArgs.method).toEqual('GET');
});
});
@@ -82,14 +76,14 @@ describe('ShlinkApiClient', () => {
const axiosSpy = sinon.spy(createAxiosMock({
data: expectedShortUrl,
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const { getShortUrl } = new ShlinkApiClient(axiosSpy);
const result = await apiClient.getShortUrl('abc123');
const result = await getShortUrl('abc123');
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(expectedShortUrl).toEqual(result);
expect(axiosArgs.url).toContain('/short-codes/abc123');
expect(axiosArgs.url).toContain('/short-urls/abc123');
expect(axiosArgs.method).toEqual('GET');
});
});
@@ -100,14 +94,14 @@ describe('ShlinkApiClient', () => {
const axiosSpy = sinon.spy(createAxiosMock({
data: { tags: expectedTags },
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy);
const result = await apiClient.updateShortUrlTags('abc123', expectedTags);
const result = await updateShortUrlTags('abc123', expectedTags);
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(expectedTags).toEqual(result);
expect(axiosArgs.url).toContain('/short-codes/abc123/tags');
expect(axiosArgs.url).toContain('/short-urls/abc123/tags');
expect(axiosArgs.method).toEqual('PUT');
});
});
@@ -120,9 +114,9 @@ describe('ShlinkApiClient', () => {
tags: { data: expectedTags },
},
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const { listTags } = new ShlinkApiClient(axiosSpy);
const result = await apiClient.listTags();
const result = await listTags();
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
@@ -136,9 +130,9 @@ describe('ShlinkApiClient', () => {
it('properly deletes provided tags', async () => {
const tags = [ 'foo', 'bar' ];
const axiosSpy = sinon.spy(createAxiosMock({}));
const apiClient = new ShlinkApiClient(axiosSpy);
const { deleteTags } = new ShlinkApiClient(axiosSpy);
await apiClient.deleteTags(tags);
await deleteTags(tags);
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
@@ -149,13 +143,13 @@ describe('ShlinkApiClient', () => {
});
describe('editTag', () => {
it('properly deletes provided tags', async () => {
it('properly edits provided tag', async () => {
const oldName = 'foo';
const newName = 'bar';
const axiosSpy = sinon.spy(createAxiosMock({}));
const apiClient = new ShlinkApiClient(axiosSpy);
const { editTag } = new ShlinkApiClient(axiosSpy);
await apiClient.editTag(oldName, newName);
await editTag(oldName, newName);
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
@@ -164,4 +158,18 @@ describe('ShlinkApiClient', () => {
expect(axiosArgs.data).toEqual({ oldName, newName });
});
});
describe('deleteShortUrl', () => {
it('properly deletes provided short URL', async () => {
const axiosSpy = sinon.spy(createAxiosMock({}));
const { deleteShortUrl } = new ShlinkApiClient(axiosSpy);
await deleteShortUrl('abc123');
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(axiosArgs.url).toContain('/short-urls/abc123');
expect(axiosArgs.method).toEqual('DELETE');
});
});
});

108
test/utils/utils.test.js Normal file
View File

@@ -0,0 +1,108 @@
import * as sinon from 'sinon';
import L from 'leaflet';
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import {
stateFlagTimeout as stateFlagTimeoutFactory,
determineOrderDir,
fixLeafletIcons,
rangeOf,
roundTen,
} from '../../src/utils/utils';
describe('utils', () => {
describe('stateFlagTimeout', () => {
it('sets state and initializes timeout with provided delay', () => {
const setTimeout = sinon.fake((callback) => callback());
const setState = sinon.spy();
const stateFlagTimeout = stateFlagTimeoutFactory(setTimeout);
const delay = 5000;
const expectedSetStateCalls = 2;
stateFlagTimeout(setState, 'foo', false, delay);
expect(setState.callCount).toEqual(expectedSetStateCalls);
expect(setState.getCall(0).args).toEqual([{ foo: false }]);
expect(setState.getCall(1).args).toEqual([{ foo: true }]);
expect(setTimeout.callCount).toEqual(1);
expect(setTimeout.getCall(0).args[1]).toEqual(delay);
});
});
describe('determineOrderDir', () => {
it('returns ASC when current order field and selected field are different', () => {
expect(determineOrderDir('foo', 'bar')).toEqual('ASC');
expect(determineOrderDir('bar', 'foo')).toEqual('ASC');
});
it('returns ASC when no current order dir is provided', () => {
expect(determineOrderDir('foo', 'foo')).toEqual('ASC');
expect(determineOrderDir('bar', 'bar')).toEqual('ASC');
});
it('returns DESC when current order field and selected field are equal and current order dir is ASC', () => {
expect(determineOrderDir('foo', 'foo', 'ASC')).toEqual('DESC');
expect(determineOrderDir('bar', 'bar', 'ASC')).toEqual('DESC');
});
it('returns undefined when current order field and selected field are equal and current order dir is DESC', () => {
expect(determineOrderDir('foo', 'foo', 'DESC')).toBeUndefined();
expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined();
});
});
describe('fixLeafletIcons', () => {
it('updates icons used by leaflet', () => {
fixLeafletIcons();
const { iconRetinaUrl, iconUrl, shadowUrl } = L.Icon.Default.prototype.options;
expect(iconRetinaUrl).toEqual(marker2x);
expect(iconUrl).toEqual(marker);
expect(shadowUrl).toEqual(markerShadow);
});
});
describe('rangeOf', () => {
const func = (i) => `result_${i}`;
const size = 5;
it('builds a range of specified size invike provided function', () => {
expect(rangeOf(size, func)).toEqual([
'result_1',
'result_2',
'result_3',
'result_4',
'result_5',
]);
});
it('builds a range starting at provided pos', () => {
const startAt = 3;
expect(rangeOf(size, func, startAt)).toEqual([
'result_3',
'result_4',
'result_5',
]);
});
});
describe('roundTen', () => {
it('rounds provided number to the next multiple of ten', () => {
const expectationsPairs = [
[ 10, 10 ],
[ 12, 20 ],
[ 158, 160 ],
[ 5, 10 ],
[ -42, -40 ],
];
expect.assertions(expectationsPairs.length);
expectationsPairs.forEach(([ number, expected ]) => {
expect(roundTen(number)).toEqual(expected);
});
});
});
});

View File

@@ -39,7 +39,7 @@ describe('<GraphCard />', () => {
]);
expect(borderColor).toEqual('white');
expect(legend).toEqual({ position: 'right' });
expect(scales).toBeNull();
expect(scales).toBeUndefined();
});
it('renders HorizontalBar when is not a bar chart', () => {

View File

@@ -11,19 +11,16 @@ import SortableBarGraph from '../../src/visits/SortableBarGraph';
describe('<ShortUrlVisits />', () => {
let wrapper;
const statsProcessor = () => ({});
const processStatsFromVisits = () => (
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
);
const getShortUrlVisitsMock = sinon.spy();
const match = {
params: { shortCode: 'abc123' },
};
const createComponent = (shortUrlVisits) => {
const ShortUrlVisits = createShortUrlVisits({
processBrowserStats: statsProcessor,
processCountriesStats: statsProcessor,
processOsStats: statsProcessor,
processReferrersStats: statsProcessor,
});
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits });
wrapper = shallow(
<ShortUrlVisits
@@ -32,6 +29,7 @@ describe('<ShortUrlVisits />', () => {
match={match}
shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}}
cancelGetShortUrlVisits={identity}
/>
);
@@ -46,7 +44,7 @@ describe('<ShortUrlVisits />', () => {
}
});
it('Renders a preloader when visits are loading', () => {
it('renders a preloader when visits are loading', () => {
const wrapper = createComponent({ loading: true });
const loadingMessage = wrapper.find(MutedMessage);
@@ -54,6 +52,14 @@ describe('<ShortUrlVisits />', () => {
expect(loadingMessage.html()).toContain('Loading...');
});
it('renders a warning when loading large amounts of visits', () => {
const wrapper = createComponent({ loading: true, loadingLarge: true });
const loadingMessage = wrapper.find(MutedMessage);
expect(loadingMessage).toHaveLength(1);
expect(loadingMessage.html()).toContain('This is going to take a while... :S');
});
it('renders an error message when visits could not be loaded', () => {
const wrapper = createComponent({ loading: false, error: true });
const errorMessage = wrapper.find(Card);
@@ -74,7 +80,7 @@ describe('<ShortUrlVisits />', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
const graphs = wrapper.find(GraphCard);
const sortableBarGraphs = wrapper.find(SortableBarGraph);
const expectedGraphsCount = 4;
const expectedGraphsCount = 5;
expect(graphs.length + sortableBarGraphs.length).toEqual(expectedGraphsCount);
});
@@ -91,4 +97,13 @@ describe('<ShortUrlVisits />', () => {
expect(getShortUrlVisitsMock.callCount).toEqual(expectedGetShortUrlVisitsCalls);
expect(wrapper.state('startDate')).toEqual('2016-01-03T00:00:00+01:00');
});
it('holds the map button content generator on cities graph extraHeaderContent', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]');
const extraHeaderContent = citiesGraph.prop('extraHeaderContent');
expect(extraHeaderContent).toHaveLength(1);
expect(typeof extraHeaderContent).toEqual('function');
});
});

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