Merge pull request #25 from acelaya/feature/1.0.0

Feature/1.0.0
This commit is contained in:
Alejandro Celaya 2018-08-26 20:26:02 +02:00 committed by GitHub
commit cddca4abcf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
104 changed files with 5283 additions and 1515 deletions

40
.eslintrc Normal file
View file

@ -0,0 +1,40 @@
{
"extends": [
"adidas-env/browser",
"adidas-env/module",
"adidas-es6",
"adidas-babel",
"adidas-react"
],
"plugins": ["jest"],
"env": {
"jest/globals": true
},
"globals": {
"process": true,
"setImmediate": true
},
"settings": {
"react": {
"version": "16.3"
}
},
"rules": {
"comma-dangle": ["error", "always-multiline"],
"no-invalid-this": "off",
"no-console": "warn",
"template-curly-spacing": ["error", "never"],
"no-warning-comments": "off",
"no-undefined": "off",
"indent": ["error", 2, {
"SwitchCase": 1
}
],
"react/jsx-curly-spacing": ["error", "never"],
"react/jsx-indent-props": ["error", 2],
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
"react/no-array-index-key": "off",
"react/no-did-update-set-state": "off"
}
}

5
.scrutinizer.yml Normal file
View file

@ -0,0 +1,5 @@
build:
environment:
node: v10.4.1
tools:
external_code_coverage: true

11
.stylelintrc Normal file
View file

@ -0,0 +1,11 @@
{
"extends": [
"stylelint-config-adidas",
"stylelint-config-adidas-bem",
"stylelint-config-recommended-scss"
],
"syntax": "scss",
"plugins": [
"stylelint-scss"
]
}

View file

@ -12,8 +12,11 @@ install:
- yarn install - yarn install
script: script:
# - yarn inspect - yarn lint
- yarn test - yarn test:ci
- yarn build # Make sure the app can be built without errors - yarn build # Make sure the app can be built without errors
after_script:
- yarn ocular coverage/clover.xml
sudo: false sudo: false

View file

@ -1,5 +1,44 @@
# CHANGELOG # CHANGELOG
## 1.0.0 - 2018-08-26
#### Added
* [#4](https://github.com/shlinkio/shlink-web-client/issues/4) Now it is possible to export and import servers.
* Export all servers in a CSV file.
* Import the CSV in a different device.
* [#4](https://github.com/shlinkio/shlink-web-client/issues/4) Added tags management.
* List existing tags, and filter the list.
* Change their name and color.
* Jump to URLs list filtering by one tag.
* [#5](https://github.com/shlinkio/shlink-web-client/issues/5) Tags for existing URLs can be edited now.
* [#24](https://github.com/shlinkio/shlink-web-client/issues/24) Improved left menu in mobile devices, which is now a swipeable sidebar instead of a stacked top bar.
* [#22](https://github.com/shlinkio/shlink-web-client/issues/22) Improved code coverage.
* [#28](https://github.com/shlinkio/shlink-web-client/issues/28) Added integration with [Scrutinizer](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/).
#### Changed
* [#33](https://github.com/shlinkio/shlink-web-client/issues/33) Changed to [adidas coding style](https://github.com/adidas/js-linter-configs) for Javascript.
* [#32](https://github.com/shlinkio/shlink-web-client/issues/32) Changed to [adidas coding style](https://github.com/adidas/js-linter-configs) for stylesheets.
* [#26](https://github.com/shlinkio/shlink-web-client/issues/26) The tags input now displays tags using their actual color.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 0.2.0 - 2018-08-12 ## 0.2.0 - 2018-08-12
#### Added #### Added

View file

@ -1,6 +1,10 @@
# shlink-web-client # shlink-web-client
[![Build Status](https://travis-ci.org/shlinkio/shlink-web-client.svg?branch=master)](https://travis-ci.org/shlinkio/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)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/gshlinkio/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)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
A ReactJS-based progressive web application for [Shlink](https://shlink.io). A ReactJS-based progressive web application for [Shlink](https://shlink.io).

View file

@ -1,4 +1,3 @@
'use strict';
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@ -7,7 +6,8 @@ const paths = require('./paths');
// Make sure that including paths.js after env.js will read .env variables. // Make sure that including paths.js after env.js will read .env variables.
delete require.cache[require.resolve('./paths')]; delete require.cache[require.resolve('./paths')];
const NODE_ENV = process.env.NODE_ENV; const { NODE_ENV } = process.env;
if (!NODE_ENV) { if (!NODE_ENV) {
throw new Error( throw new Error(
'The NODE_ENV environment variable is required but was not specified.' 'The NODE_ENV environment variable is required but was not specified.'
@ -15,9 +15,10 @@ if (!NODE_ENV) {
} }
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
var dotenvFiles = [ const dotenvFiles = [
`${paths.dotenv}.${NODE_ENV}.local`, `${paths.dotenv}.${NODE_ENV}.local`,
`${paths.dotenv}.${NODE_ENV}`, `${paths.dotenv}.${NODE_ENV}`,
// Don't include `.env.local` for `test` environment // Don't include `.env.local` for `test` environment
// since normally you expect tests to produce the same // since normally you expect tests to produce the same
// results for everyone // results for everyone
@ -30,7 +31,7 @@ var dotenvFiles = [
// that have already been set. Variable expansion is supported in .env files. // that have already been set. Variable expansion is supported in .env files.
// https://github.com/motdotla/dotenv // https://github.com/motdotla/dotenv
// https://github.com/motdotla/dotenv-expand // https://github.com/motdotla/dotenv-expand
dotenvFiles.forEach(dotenvFile => { dotenvFiles.forEach((dotenvFile) => {
if (fs.existsSync(dotenvFile)) { if (fs.existsSync(dotenvFile)) {
require('dotenv-expand')( require('dotenv-expand')(
require('dotenv').config({ require('dotenv').config({
@ -50,10 +51,11 @@ dotenvFiles.forEach(dotenvFile => {
// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 // https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421
// We also resolve them to make sure all tools using them work consistently. // We also resolve them to make sure all tools using them work consistently.
const appDirectory = fs.realpathSync(process.cwd()); const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || '') process.env.NODE_PATH = (process.env.NODE_PATH || '')
.split(path.delimiter) .split(path.delimiter)
.filter(folder => folder && !path.isAbsolute(folder)) .filter((folder) => folder && !path.isAbsolute(folder))
.map(folder => path.resolve(appDirectory, folder)) .map((folder) => path.resolve(appDirectory, folder))
.join(path.delimiter); .join(path.delimiter);
// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
@ -62,16 +64,19 @@ const REACT_APP = /^REACT_APP_/i;
function getClientEnvironment(publicUrl) { function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env) const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key)) .filter((key) => REACT_APP.test(key))
.reduce( .reduce(
(env, key) => { (env, key) => {
env[key] = process.env[key]; env[key] = process.env[key];
return env; return env;
}, },
{ {
// Useful for determining whether were running in production mode. // Useful for determining whether were running in production mode.
// Most importantly, it switches React into the correct mode. // Most importantly, it switches React into the correct mode.
NODE_ENV: process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || 'development',
// Useful for resolving the correct path to static assets in `public`. // Useful for resolving the correct path to static assets in `public`.
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />. // For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// This should only be used as an escape hatch. Normally you would put // This should only be used as an escape hatch. Normally you would put
@ -79,10 +84,12 @@ function getClientEnvironment(publicUrl) {
PUBLIC_URL: publicUrl, PUBLIC_URL: publicUrl,
} }
); );
// Stringify all values so we can feed into Webpack DefinePlugin // Stringify all values so we can feed into Webpack DefinePlugin
const stringified = { const stringified = {
'process.env': Object.keys(raw).reduce((env, key) => { 'process.env': Object.keys(raw).reduce((env, key) => {
env[key] = JSON.stringify(raw[key]); env[key] = JSON.stringify(raw[key]);
return env; return env;
}, {}), }, {}),
}; };

View file

@ -1,4 +1,3 @@
'use strict';
// This is a custom Jest transformer turning style imports into empty objects. // This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html // http://facebook.github.io/jest/docs/en/webpack.html

View file

@ -1,4 +1,3 @@
'use strict';
const path = require('path'); const path = require('path');

View file

@ -1,4 +1,3 @@
'use strict';
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@ -7,22 +6,23 @@ const url = require('url');
// Make sure any symlinks in the project folder are resolved: // Make sure any symlinks in the project folder are resolved:
// https://github.com/facebookincubator/create-react-app/issues/637 // https://github.com/facebookincubator/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd()); const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath); const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);
const envPublicUrl = process.env.PUBLIC_URL; const envPublicUrl = process.env.PUBLIC_URL;
function ensureSlash(path, needsSlash) { function ensureSlash(path, needsSlash) {
const hasSlash = path.endsWith('/'); const hasSlash = path.endsWith('/');
if (hasSlash && !needsSlash) { if (hasSlash && !needsSlash) {
return path.substr(path, path.length - 1); return path.substr(path, path.length - 1);
} else if (!hasSlash && needsSlash) { } else if (!hasSlash && needsSlash) {
return `${path}/`; return `${path}/`;
} else {
return path;
} }
return path;
} }
const getPublicUrl = appPackageJson => const getPublicUrl = (appPackageJson) =>
envPublicUrl || require(appPackageJson).homepage; envPublicUrl || require(appPackageJson).homepage;
// We use `PUBLIC_URL` environment variable or "homepage" field to infer // We use `PUBLIC_URL` environment variable or "homepage" field to infer
@ -34,6 +34,7 @@ const getPublicUrl = appPackageJson =>
function getServedPath(appPackageJson) { function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(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); return ensureSlash(servedUrl, true);
} }

View file

@ -1,4 +1,3 @@
'use strict';
if (typeof Promise === 'undefined') { if (typeof Promise === 'undefined') {
// Rejection tracking prevents a common issue where React gets into an // Rejection tracking prevents a common issue where React gets into an

View file

@ -1,7 +1,6 @@
'use strict';
const autoprefixer = require('autoprefixer');
const path = require('path'); const path = require('path');
const autoprefixer = require('autoprefixer');
const webpack = require('webpack'); const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
@ -9,265 +8,301 @@ const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const eslintFormatter = require('react-dev-utils/eslintFormatter'); const eslintFormatter = require('react-dev-utils/eslintFormatter');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin'); const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const getClientEnvironment = require('./env'); const getClientEnvironment = require('./env');
const paths = require('./paths'); const paths = require('./paths');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
// Webpack uses `publicPath` to determine where the app is being served from. // Webpack uses `publicPath` to determine where the app is being served from.
// In development, we always serve from the root. This makes config easier. // In development, we always serve from the root. This makes config easier.
const publicPath = '/'; const publicPath = '/';
// `publicUrl` is just like `publicPath`, but we will provide it to our app // `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. // 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. // Omit trailing slash as %PUBLIC_PATH%/xyz looks better than %PUBLIC_PATH%xyz.
const publicUrl = ''; const publicUrl = '';
// Get environment variables to inject into our app. // Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl); const env = getClientEnvironment(publicUrl);
const postCssLoader = { const postCssLoader = {
loader: require.resolve('postcss-loader'), loader: require.resolve('postcss-loader'),
options: { options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677 // Necessary for external CSS imports to work
ident: 'postcss', // https://github.com/facebookincubator/create-react-app/issues/2677
plugins: () => [ ident: 'postcss',
require('postcss-flexbugs-fixes'), plugins: () => [
autoprefixer({ require('postcss-flexbugs-fixes'),
browsers: [ autoprefixer({
'>1%', browsers: [
'last 4 versions', '>1%',
'Firefox ESR', 'last 4 versions',
'not ie < 9', // React doesn't support IE8 anyway 'Firefox ESR',
], 'not ie < 9',
flexbox: 'no-2009',
}),
], ],
}, flexbox: 'no-2009',
}),
],
},
}; };
// This is the development configuration. // This is the development configuration.
// It is focused on developer experience and fast rebuilds. // It is focused on developer experience and fast rebuilds.
// The production configuration is different and lives in a separate file. // The production configuration is different and lives in a separate file.
module.exports = { 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: 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 // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/ // See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
'react-native': 'react-native-web', devtool: 'cheap-module-source-map',
},
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. // These are the "entry points" to our application.
// It's important to do this before Babel processes the JS. // 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.
test: /\.(js|jsx|mjs)$/, entry: [
enforce: 'pre',
use: [
{
options: {
formatter: eslintFormatter,
eslintPath: require.resolve('eslint'),
}, // We ship a few polyfills by default:
loader: require.resolve('eslint-loader'), require.resolve('./polyfills'),
},
],
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). // Include an alternative client for WebpackDevServer. A client's job is to
// It enables caching results in ./node_modules/.cache/babel-loader/ // connect to WebpackDevServer by a socket and get notified about changes.
// directory for faster rebuilds. // When you save a file, the client will either apply hot updates (in case
cacheDirectory: true, // 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
// "postcss" loader applies autoprefixer to our CSS. // to bring better experience for Create React App users. You can replace
// "css" loader resolves paths in CSS and adds assets as dependencies. // the line below with these two lines if you prefer the stock client:
// "style" loader turns CSS into JS modules that inject <style> tags. // require.resolve('webpack-dev-server/client') + '?/',
// In production, we use a plugin to extract that CSS to a file, but // require.resolve('webpack/hot/dev-server'),
// in development "style" loader enables hot editing of CSS. require.resolve('react-dev-utils/webpackHotDevClient'),
{
test: /\.css$/, // Finally, this is your app's code:
use: [ paths.appIndexJs,
require.resolve('style-loader'),
{ // We include the app code last so that if there is a runtime error during
loader: require.resolve('css-loader'), // initialization, it doesn't blow up the WebpackDevServer client, and
options: { // changing JS code would still trigger a refresh.
importLoaders: 1, ],
}, output: {
},
postCssLoader, // 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
test: /\.scss$/, // served by WebpackDevServer in development. This is the JS bundle
use: ExtractTextPlugin.extract({ // containing code from all our entry points, and the Webpack runtime.
fallback: 'style-loader', filename: 'static/js/bundle.js',
use: ['css-loader', 'sass-loader', postCssLoader]
}) // There are also additional JS chunk files if you use code splitting.
}, chunkFilename: 'static/js/[name].chunk.js',
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename. // This is the URL that app is served from. We use "/" in development.
// In production, they would get copied to the `build` folder. publicPath,
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders. // Point sourcemap entries to original disk location (format as URL on Windows)
{ devtoolModuleFilenameTemplate: (info) =>
// Exclude `js` files to keep "css" loader working as it injects path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
// its runtime that would otherwise processed through "file" loader. },
// Also exclude `html` and `json` extensions so they get processed resolve: {
// by webpacks internal loaders.
exclude: [/\.(js|jsx|mjs)$/, /\.html$/, /\.json$/], // This allows you to set a fallback for where Webpack should look for modules.
loader: require.resolve('file-loader'), // We placed these paths second because we want `node_modules` to "win"
options: { // if there are any conflicts. This matches Node resolution mechanism.
name: 'static/media/[name].[hash:8].[ext]', // 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)
// ** STOP ** Are you adding a new loader? ),
// Make sure to add the new loader(s) before the "file" loader.
], // 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: [ plugins: [
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.: // Prevents users from importing files from outside of src/ (or node_modules/).
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> // This often causes confusion because we only process files within src/ with babel.
// In development, this will be an empty string. // To fix this, we prevent you from importing files out of src/ -- if you'd like to,
new InterpolateHtmlPlugin(env.raw), // please link the files into your node_modules/ and let module-resolution kick in.
// Generates an `index.html` file with the <script> injected. // Make sure your source files are compiled, as they will not be processed in any way.
new HtmlWebpackPlugin({ new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
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. module: {
node: { strictExportPresence: true,
dgram: 'empty', rules: [
fs: 'empty',
net: 'empty', // TODO: Disable require.ensure as it's not a standard language feature.
tls: 'empty', // We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
child_process: 'empty', // { parser: { requireEnsure: false } },
},
// Turn off performance hints during development because we don't do any // First, run the linter.
// splitting or minification in interest of speed. These warnings become // It's important to do this before Babel processes the JS.
// cumbersome. {
performance: { test: /\.(js|jsx|mjs)$/,
hints: false, 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,
},
}; };

View file

@ -1,7 +1,6 @@
'use strict';
const autoprefixer = require('autoprefixer');
const path = require('path'); const path = require('path');
const autoprefixer = require('autoprefixer');
const webpack = require('webpack'); const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin');
@ -16,22 +15,26 @@ const getClientEnvironment = require('./env');
// Webpack uses `publicPath` to determine where the app is being served from. // 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. // It requires a trailing slash, or the file assets will get an incorrect path.
const publicPath = paths.servedPath; const publicPath = paths.servedPath;
// Some apps do not use client-side routing with pushState. // Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths. // For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './'; const shouldUseRelativeAssetPaths = publicPath === './';
// Source maps are resource heavy and can cause out of memory issue for large source files. // Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false'; const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
// `publicUrl` is just like `publicPath`, but we will provide it to our app // `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. // 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. // Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = publicPath.slice(0, -1); const publicUrl = publicPath.slice(0, -1);
// Get environment variables to inject into our app. // Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl); const env = getClientEnvironment(publicUrl);
// Assert this just to be safe. // Assert this just to be safe.
// Development builds of React are slow and not intended for production. // Development builds of React are slow and not intended for production.
if (env.stringified['process.env'].NODE_ENV !== '"production"') { if (env.stringified['process.env'].NODE_ENV !== '"production"') {
throw new Error('Production builds must have NODE_ENV=production.'); throw new Error('Production builds must have NODE_ENV=production.');
} }
// Note: defined here because it will be used more than once. // Note: defined here because it will be used more than once.
@ -41,305 +44,342 @@ const cssFilename = 'static/css/[name].[contenthash:8].css';
// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27) // (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
// However, our output is structured with css, js and media folders. // 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. // 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 const extractTextPluginOptions = shouldUseRelativeAssetPaths
? // Making sure that the publicPath goes back to to build folder. ? { publicPath: Array(cssFilename.split('/').length).join('../') }
{publicPath: Array(cssFilename.split('/').length).join('../')} : {};
: {};
// This is the production configuration. // This is the production configuration.
// It compiles slowly and is focused on producing a fast and minimal bundle. // It compiles slowly and is focused on producing a fast and minimal bundle.
// The development configuration is different and lives in a separate file. // The development configuration is different and lives in a separate file.
module.exports = { 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: 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 // Don't attempt to continue if there are any errors.
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/ bail: true,
'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. // We generate sourcemaps in production. This is slow but gives good results.
// It's important to do this before Babel processes the JS. // You can exclude the *.map files from the build during deployment.
{ devtool: shouldUseSourceMap ? 'source-map' : false,
test: /\.(js|jsx|mjs)$/,
enforce: 'pre',
use: [
{
options: {
formatter: eslintFormatter,
eslintPath: require.resolve('eslint'),
}, // In production, we only want to load the polyfills and the app code.
loader: require.resolve('eslint-loader'), entry: [ require.resolve('./polyfills'), paths.appIndexJs ],
}, output: {
],
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 build folder.
}, path: paths.appBuild,
},
// The notation here is somewhat confusing. // Generated JS file names (with nested folders).
// "postcss" loader applies autoprefixer to our CSS. // There will be one main bundle, and one file per asynchronous chunk.
// "css" loader resolves paths in CSS and adds assets as dependencies. // We don't currently advertise code splitting but Webpack supports it.
// "style" loader normally turns CSS into JS modules injecting <style>, filename: 'static/js/[name].[chunkhash:8].js',
// but unlike in development configuration, we do something different. chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
// `ExtractTextPlugin` first applies the "postcss" and "css" loaders
// (second argument), then grabs the result CSS and puts it into a // We inferred the "public path" (such as / or /my-project) from homepage.
// separate file in our build process. This way we actually ship publicPath,
// a single CSS file in production instead of JS code injecting <style>
// tags. If you use code splitting, however, any async bundles will still // Point sourcemap entries to original disk location (format as URL on Windows)
// use the "style" loader inside the async code so CSS from them won't be devtoolModuleFilenameTemplate: (info) =>
// in the main CSS file. path
{ .relative(paths.appSrc, info.absoluteResourcePath)
test: [/\.css$/, /\.scss$/], .replace(/\\/g, '/'),
loader: ExtractTextPlugin.extract( },
Object.assign( resolve: {
{
fallback: { // This allows you to set a fallback for where Webpack should look for modules.
loader: require.resolve('style-loader'), // We placed these paths second because we want `node_modules` to "win"
options: { // if there are any conflicts. This matches Node resolution mechanism.
hmr: false, // https://github.com/facebookincubator/create-react-app/issues/253
}, modules: [ 'node_modules', paths.appNodeModules ].concat(
},
use: [ // It is guaranteed to exist because we tweak it in `env.js`
{ process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
loader: require.resolve('css-loader'), ),
options: {
importLoaders: 1, // These are the reasonable defaults supported by the Node ecosystem.
minimize: true, // We also include JSX as a common component filename extension to support
sourceMap: shouldUseSourceMap, // 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.
loader: require.resolve('sass-loader'), extensions: [ '.web.js', '.mjs', '.js', '.json', '.web.jsx', '.jsx' ],
}, alias: {
{
loader: require.resolve('postcss-loader'), // Support React Native Web
options: { // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
// Necessary for external CSS imports to work 'react-native': 'react-native-web',
// 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', // React doesn't support IE8 anyway
],
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: [ plugins: [
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.: // Prevents users from importing files from outside of src/ (or node_modules/).
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> // This often causes confusion because we only process files within src/ with babel.
// In production, it will be an empty string unless you specify "homepage" // To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// in `package.json`, in which case it will be the pathname of that URL. // please link the files into your node_modules/ and let module-resolution kick in.
new InterpolateHtmlPlugin(env.raw), // Make sure your source files are compiled, as they will not be processed in any way.
// Generates an `index.html` file with the <script> injected. new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
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);
},
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. module: {
node: { strictExportPresence: true,
dgram: 'empty', rules: [
fs: 'empty',
net: 'empty', // TODO: Disable require.ensure as it's not a standard language feature.
tls: 'empty', // We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
child_process: 'empty', // { 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,4 +1,3 @@
'use strict';
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware'); const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware'); const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
@ -11,6 +10,7 @@ const host = process.env.HOST || '0.0.0.0';
module.exports = function(proxy, allowedHost) { module.exports = function(proxy, allowedHost) {
return { return {
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote // WebpackDevServer 2.4.3 introduced a security fix that prevents remote
// websites from potentially accessing local content through DNS rebinding: // websites from potentially accessing local content through DNS rebinding:
// https://github.com/webpack/webpack-dev-server/issues/887 // https://github.com/webpack/webpack-dev-server/issues/887
@ -29,11 +29,14 @@ module.exports = function(proxy, allowedHost) {
// really know what you're doing with a special environment variable. // really know what you're doing with a special environment variable.
disableHostCheck: disableHostCheck:
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true', !proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true',
// Enable gzip compression of generated files. // Enable gzip compression of generated files.
compress: true, compress: true,
// Silence WebpackDevServer's own logs since they're generally not useful. // Silence WebpackDevServer's own logs since they're generally not useful.
// It will still show compile warnings and errors with this setting. // It will still show compile warnings and errors with this setting.
clientLogLevel: 'none', clientLogLevel: 'none',
// By default WebpackDevServer serves physical files from current directory // By default WebpackDevServer serves physical files from current directory
// in addition to all the virtual build products that it serves from memory. // in addition to all the virtual build products that it serves from memory.
// This is confusing because those files wont automatically be available in // This is confusing because those files wont automatically be available in
@ -49,20 +52,25 @@ module.exports = function(proxy, allowedHost) {
// for some reason broken when imported through Webpack. If you just want to // for some reason broken when imported through Webpack. If you just want to
// use an image, put it in `src` and `import` it from JavaScript instead. // use an image, put it in `src` and `import` it from JavaScript instead.
contentBase: paths.appPublic, contentBase: paths.appPublic,
// By default files from `contentBase` will not trigger a page reload. // By default files from `contentBase` will not trigger a page reload.
watchContentBase: true, watchContentBase: true,
// Enable hot reloading server. It will provide /sockjs-node/ endpoint // Enable hot reloading server. It will provide /sockjs-node/ endpoint
// for the WebpackDevServer client so it can learn when the files were // for the WebpackDevServer client so it can learn when the files were
// updated. The WebpackDevServer client is included as an entry point // updated. The WebpackDevServer client is included as an entry point
// in the Webpack development configuration. Note that only changes // in the Webpack development configuration. Note that only changes
// to CSS are currently hot reloaded. JS changes will refresh the browser. // to CSS are currently hot reloaded. JS changes will refresh the browser.
hot: true, hot: true,
// It is important to tell WebpackDevServer to use the same "root" path // It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /. // as we specified in the config. In development, we always serve from /.
publicPath: config.output.publicPath, publicPath: config.output.publicPath,
// WebpackDevServer is noisy by default so we emit custom message instead // 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.plugin` calls above.
quiet: true, quiet: true,
// Reportedly, this avoids CPU overload on some systems. // Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebookincubator/create-react-app/issues/293 // https://github.com/facebookincubator/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports // src/node_modules is not ignored to support absolute imports
@ -70,11 +78,13 @@ module.exports = function(proxy, allowedHost) {
watchOptions: { watchOptions: {
ignored: ignoredFiles(paths.appSrc), ignored: ignoredFiles(paths.appSrc),
}, },
// Enable HTTPS if the HTTPS environment variable is set to 'true' // Enable HTTPS if the HTTPS environment variable is set to 'true'
https: protocol === 'https', https: protocol === 'https',
host: host, host,
overlay: false, overlay: false,
historyApiFallback: { historyApiFallback: {
// Paths with dots should still use the history fallback. // Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387. // See https://github.com/facebookincubator/create-react-app/issues/387.
disableDotRule: true, disableDotRule: true,
@ -84,6 +94,7 @@ module.exports = function(proxy, allowedHost) {
before(app) { before(app) {
// This lets us open files from the runtime error overlay. // This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware()); app.use(errorOverlayMiddleware());
// This service worker file is effectively a 'no-op' that will reset any // This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination. // previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if // We do this in development to avoid hitting the production cache if

View file

@ -10,3 +10,4 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
- "56745:56745" - "56745:56745"
- "5000:5000"

View file

@ -1,12 +1,19 @@
{ {
"name": "shlink-web-client-react", "name": "shlink-web-client-react",
"description": "A React-based web client for shlink", "description": "A React-based progressive web application for shlink",
"version": "0.1.0", "version": "1.0.0",
"private": false, "private": false,
"scripts": { "scripts": {
"lint": "yarn lint:js && yarn lint:css",
"lint:js": "eslint src test scripts config",
"lint:js:fix": "yarn lint:js --fix",
"lint:css": "stylelint src/**/*.scss",
"lint:css:fix": "yarn lint:css --fix",
"start": "node scripts/start.js", "start": "node scripts/start.js",
"build": "node scripts/build.js", "build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom --colors" "test": "node scripts/test.js --env=jsdom --colors",
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome": "^1.1.8", "@fortawesome/fontawesome": "^1.1.8",
@ -16,6 +23,8 @@
"axios": "^0.18.0", "axios": "^0.18.0",
"bootstrap": "^4.1.1", "bootstrap": "^4.1.1",
"chart.js": "^2.7.2", "chart.js": "^2.7.2",
"classnames": "^2.2.6",
"csvjson": "^5.1.0",
"moment": "^2.22.2", "moment": "^2.22.2",
"promise": "8.0.1", "promise": "8.0.1",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
@ -23,12 +32,14 @@
"ramda": "^0.25.0", "ramda": "^0.25.0",
"react": "^16.3.2", "react": "^16.3.2",
"react-chartjs-2": "^2.7.4", "react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "^1.5.0", "react-datepicker": "^1.5.0",
"react-dom": "^16.3.2", "react-dom": "^16.3.2",
"react-moment": "^0.7.6", "react-moment": "^0.7.6",
"react-redux": "^5.0.7", "react-redux": "^5.0.7",
"react-router-dom": "^4.2.2", "react-router-dom": "^4.2.2",
"react-swipeable": "^4.3.0",
"react-tagsinput": "^3.19.0", "react-tagsinput": "^3.19.0",
"reactstrap": "^6.0.1", "reactstrap": "^6.0.1",
"redux": "^4.0.0", "redux": "^4.0.0",
@ -41,7 +52,7 @@
"babel-core": "6.26.0", "babel-core": "6.26.0",
"babel-eslint": "7.2.3", "babel-eslint": "7.2.3",
"babel-jest": "20.0.3", "babel-jest": "20.0.3",
"babel-loader": "7.1.2", "babel-loader": "^7.1.2",
"babel-preset-react-app": "^3.1.1", "babel-preset-react-app": "^3.1.1",
"babel-runtime": "6.26.0", "babel-runtime": "6.26.0",
"case-sensitive-paths-webpack-plugin": "2.1.1", "case-sensitive-paths-webpack-plugin": "2.1.1",
@ -51,13 +62,16 @@
"dotenv-expand": "4.2.0", "dotenv-expand": "4.2.0",
"enzyme": "^3.3.0", "enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1", "enzyme-adapter-react-16": "^1.1.1",
"eslint": "4.10.0", "eslint": "^5.4.0",
"eslint-config-react-app": "^2.1.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-loader": "1.9.0",
"eslint-plugin-flowtype": "2.39.1", "eslint-plugin-import": "^2.8.0",
"eslint-plugin-import": "2.8.0", "eslint-plugin-jest": "^21.22.0",
"eslint-plugin-jsx-a11y": "5.1.1", "eslint-plugin-promise": "^3.0.0",
"eslint-plugin-react": "7.4.0", "eslint-plugin-react": "^7.4.0",
"extract-text-webpack-plugin": "^3.0.2", "extract-text-webpack-plugin": "^3.0.2",
"file-loader": "1.1.5", "file-loader": "1.1.5",
"fs-extra": "3.0.1", "fs-extra": "3.0.1",
@ -65,14 +79,21 @@
"jest": "20.0.4", "jest": "20.0.4",
"node-sass": "^4.9.0", "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-flexbugs-fixes": "3.2.0",
"postcss-loader": "2.0.8", "postcss-loader": "2.0.8",
"raf": "3.4.0", "raf": "3.4.0",
"react-dev-utils": "^5.0.1", "react-dev-utils": "^5.0.1",
"resolve": "1.6.0", "resolve": "1.6.0",
"sass-loader": "^7.0.1", "sass-loader": "^7.0.1",
"serve": "^10.0.0",
"sinon": "^6.1.5", "sinon": "^6.1.5",
"style-loader": "0.19.0", "style-loader": "0.19.0",
"stylelint": "^9.5.0",
"stylelint-config-adidas": "^1.0.1",
"stylelint-config-adidas-bem": "^1.0.1",
"stylelint-config-recommended-scss": "^3.2.0",
"stylelint-scss": "^3.3.0",
"sw-precache-webpack-plugin": "0.11.4", "sw-precache-webpack-plugin": "0.11.4",
"url-loader": "0.6.2", "url-loader": "0.6.2",
"webpack": "3.8.1", "webpack": "3.8.1",
@ -81,6 +102,7 @@
"whatwg-fetch": "2.0.3" "whatwg-fetch": "2.0.3"
}, },
"jest": { "jest": {
"coverageDirectory": "<rootDir>/coverage",
"collectCoverageFrom": [ "collectCoverageFrom": [
"src/**/*.{js,jsx,mjs}" "src/**/*.{js,jsx,mjs}"
], ],
@ -118,8 +140,5 @@
"presets": [ "presets": [
"react-app" "react-app"
] ]
},
"eslintConfig": {
"extends": "react-app"
} }
} }

View file

@ -1,4 +1,4 @@
'use strict'; /* eslint no-console: "off" */
// Do this as the first thing so that any code reading it knows the right env. // Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production'; process.env.BABEL_ENV = 'production';
@ -7,7 +7,7 @@ process.env.NODE_ENV = 'production';
// Makes the script crash on unhandled rejections instead of silently // Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will // ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code. // terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => { process.on('unhandledRejection', (err) => {
throw err; throw err;
}); });
@ -18,38 +18,38 @@ const path = require('path');
const chalk = require('chalk'); const chalk = require('chalk');
const fs = require('fs-extra'); const fs = require('fs-extra');
const webpack = require('webpack'); const webpack = require('webpack');
const config = require('../config/webpack.config.prod');
const paths = require('../config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError'); const printBuildError = require('react-dev-utils/printBuildError');
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
const paths = require('../config/paths');
const config = require('../config/webpack.config.prod');
const measureFileSizesBeforeBuild = const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
const useYarn = fs.existsSync(paths.yarnLockFile); const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them. // These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // eslint-disable-line
// Warn and crash if required files are missing // Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
process.exit(1); process.exit(1);
} }
// First, read the current file sizes in build directory. // First, read the current file sizes in build directory.
// This lets us display how much they changed later. // This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appBuild) measureFileSizesBeforeBuild(paths.appBuild)
.then(previousFileSizes => { .then((previousFileSizes) => {
// Remove all content but keep the directory so that // Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash // if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appBuild); fs.emptyDirSync(paths.appBuild);
// Merge with the public folder // Merge with the public folder
copyPublicFolder(); copyPublicFolder();
// Start the webpack build // Start the webpack build
return build(previousFileSizes); return build(previousFileSizes);
}) })
@ -59,14 +59,14 @@ measureFileSizesBeforeBuild(paths.appBuild)
console.log(chalk.yellow('Compiled with warnings.\n')); console.log(chalk.yellow('Compiled with warnings.\n'));
console.log(warnings.join('\n\n')); console.log(warnings.join('\n\n'));
console.log( console.log(
'\nSearch for the ' + `\nSearch for the ${
chalk.underline(chalk.yellow('keywords')) + chalk.underline(chalk.yellow('keywords'))
' to learn more about each warning.' } to learn more about each warning.`
); );
console.log( console.log(
'To ignore, add ' + `To ignore, add ${
chalk.cyan('// eslint-disable-next-line') + chalk.cyan('// eslint-disable-next-line')
' to the line before.\n' } to the line before.\n`
); );
} else { } else {
console.log(chalk.green('Compiled successfully.\n')); console.log(chalk.green('Compiled successfully.\n'));
@ -83,9 +83,11 @@ measureFileSizesBeforeBuild(paths.appBuild)
console.log(); console.log();
const appPackage = require(paths.appPackageJson); const appPackage = require(paths.appPackageJson);
const publicUrl = paths.publicUrl;
const publicPath = config.output.publicPath; const { publicUrl } = paths;
const { publicPath } = config.output;
const buildFolder = path.relative(process.cwd(), paths.appBuild); const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions( printHostingInstructions(
appPackage, appPackage,
publicUrl, publicUrl,
@ -94,7 +96,7 @@ measureFileSizesBeforeBuild(paths.appBuild)
useYarn useYarn
); );
}, },
err => { (err) => {
console.log(chalk.red('Failed to compile.\n')); console.log(chalk.red('Failed to compile.\n'));
printBuildError(err); printBuildError(err);
process.exit(1); process.exit(1);
@ -106,19 +108,23 @@ measureFileSizesBeforeBuild(paths.appBuild)
function build(previousFileSizes) { function build(previousFileSizes) {
console.log('Creating an optimized production build...'); console.log('Creating an optimized production build...');
let compiler = webpack(config); const compiler = webpack(config);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
compiler.run((err, stats) => { compiler.run((err, stats) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
const messages = formatWebpackMessages(stats.toJson({}, true)); const messages = formatWebpackMessages(stats.toJson({}, true));
if (messages.errors.length) { if (messages.errors.length) {
// Only keep the first error. Others are often indicative // Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise. // of the same problem, but confuse the reader with noise.
if (messages.errors.length > 1) { if (messages.errors.length > 1) {
messages.errors.length = 1; messages.errors.length = 1;
} }
return reject(new Error(messages.errors.join('\n\n'))); return reject(new Error(messages.errors.join('\n\n')));
} }
if ( if (
@ -133,8 +139,10 @@ function build(previousFileSizes) {
'Most CI servers set it automatically.\n' 'Most CI servers set it automatically.\n'
) )
); );
return reject(new Error(messages.warnings.join('\n\n'))); return reject(new Error(messages.warnings.join('\n\n')));
} }
return resolve({ return resolve({
stats, stats,
previousFileSizes, previousFileSizes,
@ -147,17 +155,19 @@ function build(previousFileSizes) {
function copyPublicFolder() { function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appBuild, { fs.copySync(paths.appPublic, paths.appBuild, {
dereference: true, dereference: true,
filter: file => file !== paths.appHtml, filter: (file) => file !== paths.appHtml,
}); });
} }
function zipDist() { function zipDist() {
const minArgsToContainVersion = 3;
// If no version was provided, do nothing // If no version was provided, do nothing
if (process.argv.length < 3) { if (process.argv.length < minArgsToContainVersion) {
return; return;
} }
const version = process.argv[2]; const [ , , version ] = process.argv;
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`; const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`)); console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
@ -167,6 +177,7 @@ function zipDist() {
if (fs.existsSync(versionFileName)) { if (fs.existsSync(versionFileName)) {
fs.unlink(versionFileName); fs.unlink(versionFileName);
} }
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`); zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName); zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated')); console.log(chalk.green('Dist file properly generated'));

View file

@ -1,4 +1,4 @@
'use strict'; /* eslint no-console: "off" */
// Do this as the first thing so that any code reading it knows the right env. // Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development'; process.env.BABEL_ENV = 'development';
@ -7,7 +7,7 @@ process.env.NODE_ENV = 'development';
// Makes the script crash on unhandled rejections instead of silently // Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will // ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code. // terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => { process.on('unhandledRejection', (err) => {
throw err; throw err;
}); });
@ -35,12 +35,13 @@ const useYarn = fs.existsSync(paths.yarnLockFile);
const isInteractive = process.stdout.isTTY; const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing // Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
process.exit(1); process.exit(1);
} }
// Tools like Cloud9 rely on this. // Tools like Cloud9 rely on this.
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; const FALLBACK_PORT = 3000;
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || FALLBACK_PORT;
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) { if (process.env.HOST) {
@ -52,7 +53,7 @@ if (process.env.HOST) {
) )
); );
console.log( console.log(
`If this was unintentional, check that you haven't mistakenly set it in your shell.` '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/2mwWSwH')}`);
console.log(); console.log();
@ -61,47 +62,59 @@ if (process.env.HOST) {
// We attempt to use the default port but if it is busy, we offer the user to // 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. // run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, DEFAULT_PORT) choosePort(HOST, DEFAULT_PORT)
.then(port => { .then((port) => {
if (port == null) { if (port === null) {
// We have not found a port. // We have not found a port.
return; return;
} }
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name; const appName = require(paths.appPackageJson).name;
const urls = prepareUrls(protocol, HOST, port); const urls = prepareUrls(protocol, HOST, port);
// Create a webpack compiler that is configured with custom messages. // Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler(webpack, config, appName, urls, useYarn); const compiler = createCompiler(webpack, config, appName, urls, useYarn);
// Load proxy config // Load proxy config
const proxySetting = require(paths.appPackageJson).proxy; const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(proxySetting, paths.appPublic); 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 sever.
const serverConfig = createDevServerConfig( const serverConfig = createDevServerConfig(
proxyConfig, proxyConfig,
urls.lanUrlForConfig urls.lanUrlForConfig
); );
const devServer = new WebpackDevServer(compiler, serverConfig); const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer. // Launch WebpackDevServer.
devServer.listen(port, HOST, err => {
devServer.listen(port, HOST, (err) => {
if (err) { if (err) {
return console.log(err); console.log(err);
return;
} }
if (isInteractive) { if (isInteractive) {
clearConsole(); clearConsole();
} }
console.log(chalk.cyan('Starting the development server...\n')); console.log(chalk.cyan('Starting the development server...\n'));
openBrowser(urls.localUrlForBrowser); openBrowser(urls.localUrlForBrowser);
}); });
['SIGINT', 'SIGTERM'].forEach(function(sig) { [ 'SIGINT', 'SIGTERM' ].forEach((sig) => {
process.on(sig, function() { process.on(sig, () => {
devServer.close(); devServer.close();
process.exit(); process.exit();
}); });
}); });
}) })
.catch(err => { .catch((err) => {
if (err && err.message) { if (err && err.message) {
console.log(err.message); console.log(err.message);
} }
process.exit(1); process.exit(1);
}); });

View file

@ -1,4 +1,3 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env. // Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'test'; process.env.BABEL_ENV = 'test';
@ -8,7 +7,7 @@ process.env.PUBLIC_URL = '';
// Makes the script crash on unhandled rejections instead of silently // Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will // ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code. // terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => { process.on('unhandledRejection', (err) => {
throw err; throw err;
}); });
@ -17,6 +16,8 @@ require('../config/env');
// Make tests to be matched inside tests folder // Make tests to be matched inside tests folder
const jest = require('jest'); const jest = require('jest');
let argv = process.argv.slice(2);
const argumentsToRemove = 2;
const argv = process.argv.slice(argumentsToRemove);
jest.run(argv); jest.run(argv);

View file

@ -6,20 +6,18 @@ import MainHeader from './common/MainHeader';
import MenuLayout from './common/MenuLayout'; import MenuLayout from './common/MenuLayout';
import CreateServer from './servers/CreateServer'; import CreateServer from './servers/CreateServer';
export default class App extends React.Component { export default function App() {
render() { return (
return ( <div className="container-fluid app-container">
<div className="container-fluid"> <MainHeader />
<MainHeader/>
<div className="app"> <div className="app">
<Switch> <Switch>
<Route exact path="/server/create" component={CreateServer} /> <Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} /> <Route path="/server/:serverId" component={MenuLayout} />
</Switch> </Switch>
</div>
</div> </div>
); </div>
} );
} }

View file

@ -1,5 +1,10 @@
@import './utils/base'; @import './utils/base';
.app-container {
height: 100%;
}
.app { .app {
padding-top: $headerHeight; padding-top: $headerHeight;
height: 100%;
} }

View file

@ -3,6 +3,7 @@ import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda'; import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1'; const API_VERSION = '1';
const STATUS_UNAUTHORIZED = 401;
export class ShlinkApiClient { export class ShlinkApiClient {
constructor(axios) { constructor(axios) {
@ -14,8 +15,6 @@ export class ShlinkApiClient {
/** /**
* Sets the base URL to be used on any request * Sets the base URL to be used on any request
* @param {String} baseUrl
* @param {String} apiKey
*/ */
setConfig = ({ url, apiKey }) => { setConfig = ({ url, apiKey }) => {
this._baseUrl = `${url}/rest/v${API_VERSION}`; this._baseUrl = `${url}/rest/v${API_VERSION}`;
@ -24,27 +23,48 @@ export class ShlinkApiClient {
listShortUrls = (options = {}) => listShortUrls = (options = {}) =>
this._performRequest('/short-codes', 'GET', options) this._performRequest('/short-codes', 'GET', options)
.then(resp => resp.data.shortUrls) .then((resp) => resp.data.shortUrls)
.catch(e => this._handleAuthError(e, this.listShortUrls, [options])); .catch((e) => this._handleAuthError(e, this.listShortUrls, [ options ]));
createShortUrl = (options) => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
createShortUrl = options => {
const filteredOptions = reject(value => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-codes', 'POST', {}, filteredOptions) return this._performRequest('/short-codes', 'POST', {}, filteredOptions)
.then(resp => resp.data) .then((resp) => resp.data)
.catch(e => this._handleAuthError(e, this.createShortUrl, [filteredOptions])); .catch((e) => this._handleAuthError(e, this.createShortUrl, [ filteredOptions ]));
}; };
getShortUrlVisits = (shortCode, dates) => getShortUrlVisits = (shortCode, dates) =>
this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates) this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates)
.then(resp => resp.data.visits.data) .then((resp) => resp.data.visits.data)
.catch(e => this._handleAuthError(e, this.getShortUrlVisits, [shortCode, dates])); .catch((e) => this._handleAuthError(e, this.getShortUrlVisits, [ shortCode, dates ]));
getShortUrl = shortCode => getShortUrl = (shortCode) =>
this._performRequest(`/short-codes/${shortCode}`, 'GET') this._performRequest(`/short-codes/${shortCode}`, 'GET')
.then(resp => resp.data) .then((resp) => resp.data)
.catch(e => this._handleAuthError(e, this.getShortUrl, [shortCode])); .catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ]));
_performRequest = async (url, method = 'GET', params = {}, data = {}) => { 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 ]));
listTags = () =>
this._performRequest('/tags', 'GET')
.then((resp) => resp.data.tags.data)
.catch((e) => this._handleAuthError(e, this.listTags, []));
deleteTags = (tags) =>
this._performRequest('/tags', 'DELETE', { tags })
.then(() => ({ tags }))
.catch((e) => this._handleAuthError(e, this.deleteTags, [ tags ]));
editTag = (oldName, newName) =>
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }))
.catch((e) => this._handleAuthError(e, this.editTag, [ oldName, newName ]));
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
if (isEmpty(this._token)) { if (isEmpty(this._token)) {
this._token = await this._authenticate(); this._token = await this._authenticate();
} }
@ -52,14 +72,16 @@ export class ShlinkApiClient {
return await this.axios({ return await this.axios({
method, method,
url: `${this._baseUrl}${url}`, url: `${this._baseUrl}${url}`,
headers: { 'Authorization': `Bearer ${this._token}` }, headers: { Authorization: `Bearer ${this._token}` },
params, params: query,
data, data: body,
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'brackets' }) paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
}).then(resp => { }).then((resp) => {
// Save new token // Save new token
const { authorization = '' } = resp.headers; const { authorization = '' } = resp.headers;
this._token = authorization.substr('Bearer '.length); this._token = authorization.substr('Bearer '.length);
return resp; return resp;
}); });
}; };
@ -68,15 +90,17 @@ export class ShlinkApiClient {
const resp = await this.axios({ const resp = await this.axios({
method: 'POST', method: 'POST',
url: `${this._baseUrl}/authenticate`, url: `${this._baseUrl}/authenticate`,
data: { apiKey: this._apiKey } data: { apiKey: this._apiKey },
}); });
return resp.data.token; return resp.data.token;
}; };
_handleAuthError = (e, method, args) => { _handleAuthError = (e, method, args) => {
// If auth failed, reset token to force it to be regenerated, and perform a new request // If auth failed, reset token to force it to be regenerated, and perform a new request
if (e.response.status === 401) { if (e.response.status === STATUS_UNAUTHORIZED) {
this._token = ''; this._token = '';
return method(...args); return method(...args);
} }
@ -85,4 +109,6 @@ export class ShlinkApiClient {
}; };
} }
export default new ShlinkApiClient(axios); const shlinkApiClient = new ShlinkApiClient(axios);
export default shlinkApiClient;

View file

@ -1,17 +1,33 @@
import listIcon from '@fortawesome/fontawesome-free-solid/faBars'; import listIcon from '@fortawesome/fontawesome-free-solid/faList';
import createIcon from '@fortawesome/fontawesome-free-solid/faPlus'; import createIcon from '@fortawesome/fontawesome-free-solid/faLink';
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import DeleteServerButton from '../servers/DeleteServerButton'; import DeleteServerButton from '../servers/DeleteServerButton';
import './AsideMenu.scss'; import './AsideMenu.scss';
import PropTypes from 'prop-types'; import { serverType } from '../servers/prop-types';
export default function AsideMenu({ selectedServer }) { const defaultProps = {
className: '',
showOnMobile: false,
};
const propTypes = {
selectedServer: serverType,
className: PropTypes.string,
showOnMobile: PropTypes.bool,
};
export default function AsideMenu({ selectedServer, className, showOnMobile }) {
const serverId = selectedServer ? selectedServer.id : ''; const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
return ( return (
<aside className="aside-menu col-lg-2 col-md-3"> <aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav"> <nav className="nav flex-column aside-menu__nav">
<NavLink <NavLink
className="aside-menu__item" className="aside-menu__item"
@ -26,10 +42,19 @@ export default function AsideMenu({ selectedServer }) {
activeClassName="aside-menu__item--selected" activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`} to={`/server/${serverId}/create-short-url`}
> >
<FontAwesomeIcon icon={createIcon} /> <FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span> <span className="aside-menu__item-text">Create short URL</span>
</NavLink> </NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
<DeleteServerButton <DeleteServerButton
className="aside-menu__item aside-menu__item--danger" className="aside-menu__item aside-menu__item--danger"
server={selectedServer} server={selectedServer}
@ -39,11 +64,5 @@ export default function AsideMenu({ selectedServer }) {
); );
} }
AsideMenu.propTypes = { AsideMenu.defaultProps = defaultProps;
selectedServer: PropTypes.shape({ AsideMenu.propTypes = propTypes;
id: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
}),
};

View file

@ -1,28 +1,44 @@
@import '../utils/base'; @import '../utils/base';
@import '../utils/mixins/box-shadow';
@import '../utils/mixins/vertical-align';
$asideMenuMobileWidth: 280px;
.aside-menu { .aside-menu {
background-color: #f7f7f7; background-color: #f7f7f7;
padding-top: 10px; position: fixed !important;
padding-top: 13px;
padding-bottom: 10px; padding-bottom: 10px;
top: $headerHeight;
bottom: 0;
left: 0;
display: block;
z-index: 1010;
overflow-x: hidden;
overflow-y: auto;
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
position: fixed !important;
top: $headerHeight;
bottom: 0;
left: 0;
z-index: 1000;
display: block;
padding: 30px 15px 15px; padding: 30px 15px 15px;
overflow-x: hidden;
overflow-y: auto;
border-right: 1px solid #eee; border-right: 1px solid #eee;
} }
@media (max-width: $smMax) {
width: $asideMenuMobileWidth !important;
transition: left 300ms;
top: $headerHeight - 3px;
@include box-shadow(-10px 0 50px 11px rgba(0, 0, 0, .55));
}
}
.aside-menu--hidden {
@media (max-width: $smMax) {
left: -($asideMenuMobileWidth + 35px);
}
} }
.aside-menu__nav { .aside-menu__nav {
@media(min-width: $smMin) { height: 100%;
height: 100%;
}
} }
.aside-menu__item { .aside-menu__item {
@ -31,6 +47,7 @@
text-decoration: none !important; text-decoration: none !important;
cursor: pointer; cursor: pointer;
} }
.aside-menu__item:hover { .aside-menu__item:hover {
background-color: $lightHoverColor; background-color: $lightHoverColor;
} }
@ -39,6 +56,7 @@
color: #fff; color: #fff;
background-color: $mainColor; background-color: $mainColor;
} }
.aside-menu__item--selected:hover { .aside-menu__item--selected:hover {
color: #fff; color: #fff;
background-color: $mainColor; background-color: $mainColor;
@ -53,6 +71,7 @@
color: $dangerColor; color: $dangerColor;
margin-top: auto; margin-top: auto;
} }
.aside-menu__item--danger:hover { .aside-menu__item--danger:hover {
color: #fff; color: #fff;
background-color: $dangerColor; background-color: $dangerColor;

View file

@ -12,16 +12,18 @@
.date-input-container__icon { .date-input-container__icon {
@include vertical-align(); @include vertical-align();
right: .75rem; right: .75rem;
cursor: pointer; cursor: pointer;
} }
.react-datepicker__close-icon.react-datepicker__close-icon { .react-datepicker__close-icon.react-datepicker__close-icon {
@include vertical-align(); @include vertical-align();
right: 0; right: 0;
} }
.react-datepicker__close-icon.react-datepicker__close-icon::after { .react-datepicker__close-icon.react-datepicker__close-icon:after {
right: .75rem; right: .75rem;
line-height: 11px; line-height: 11px;
background-color: #333; background-color: #333;

View file

@ -1,14 +1,20 @@
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight' import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
import FontAwesomeIcon from '@fortawesome/react-fontawesome' import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, pick, values } from 'ramda' import { isEmpty, pick, values } from 'ramda';
import React from 'react' import React from 'react';
import { connect } from 'react-redux' import { connect } from 'react-redux';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap' import { ListGroup, ListGroupItem } from 'reactstrap';
import { resetSelectedServer } from '../servers/reducers/selectedServer' import PropTypes from 'prop-types';
import './Home.scss' import { resetSelectedServer } from '../servers/reducers/selectedServer';
import './Home.scss';
export class Home extends React.Component { const propTypes = {
resetSelectedServer: PropTypes.func,
servers: PropTypes.object,
};
export class HomeComponent extends React.Component {
componentDidMount() { componentDidMount() {
this.props.resetSelectedServer(); this.props.resetSelectedServer();
} }
@ -45,4 +51,8 @@ export class Home extends React.Component {
} }
} }
export default connect(pick(['servers']), { resetSelectedServer })(Home); HomeComponent.propTypes = propTypes;
const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent);
export default Home;

View file

@ -11,7 +11,11 @@
} }
.home__title { .home__title {
font-size: 36px; font-size: 1.75rem;
@media (min-width: $mdMin) {
font-size: 2.2rem;
}
} }
.home__servers-list { .home__servers-list {
@ -23,10 +27,11 @@
.home__servers-item.home__servers-item { .home__servers-item.home__servers-item {
text-align: left; text-align: left;
position: relative; position: relative;
padding: .75rem 2.5rem 0.75rem 1rem; padding: .75rem 2.5rem .75rem 1rem;
} }
.home__servers-item-icon { .home__servers-item-icon {
@include vertical-align(); @include vertical-align();
right: 1rem; right: 1rem;
} }

View file

@ -1,17 +1,24 @@
import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus'; import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { Link, withRouter } from 'react-router-dom' import { Link, withRouter } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap'; import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import ServersDropdown from '../servers/ServersDropdown'; import ServersDropdown from '../servers/ServersDropdown';
import './MainHeader.scss'; import './MainHeader.scss';
import shlinkLogo from './shlink-logo-white.png'; import shlinkLogo from './shlink-logo-white.png';
export class MainHeader extends React.Component { const propTypes = {
location: PropTypes.object,
};
export class MainHeaderComponent extends React.Component {
state = { isOpen: false }; state = { isOpen: false };
toggle = () => { handleToggle = () => {
this.setState(({ isOpen }) => ({ this.setState(({ isOpen }) => ({
isOpen: !isOpen isOpen: !isOpen,
})); }));
}; };
@ -22,17 +29,31 @@ export class MainHeader extends React.Component {
} }
render() { render() {
const { location } = this.props;
const createServerPath = '/server/create';
const toggleClass = classnames('main-header__toggle-icon', {
'main-header__toggle-icon--opened': this.state.isOpen,
});
return ( return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md"> <Navbar color="primary" dark fixed="top" className="main-header" expand="md">
<NavbarBrand tag={Link} to="/"> <NavbarBrand tag={Link} to="/">
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo"/> Shlink <img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
</NavbarBrand> </NavbarBrand>
<NavbarToggler onClick={this.toggle} />
<NavbarToggler onClick={this.handleToggle}>
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
</NavbarToggler>
<Collapse navbar isOpen={this.state.isOpen}> <Collapse navbar isOpen={this.state.isOpen}>
<Nav navbar className="ml-auto"> <Nav navbar className="ml-auto">
<NavItem> <NavItem>
<NavLink tag={Link} to="/server/create"> <NavLink
<FontAwesomeIcon icon={plusIcon}/>&nbsp; Add server tag={Link}
to={createServerPath}
active={location.pathname === createServerPath}
>
<FontAwesomeIcon icon={plusIcon} />&nbsp; Add server
</NavLink> </NavLink>
</NavItem> </NavItem>
<ServersDropdown /> <ServersDropdown />
@ -43,4 +64,8 @@ export class MainHeader extends React.Component {
} }
} }
export default withRouter(MainHeader); MainHeaderComponent.propTypes = propTypes;
const MainHeader = withRouter(MainHeaderComponent);
export default MainHeader;

View file

@ -13,3 +13,12 @@
width: 26px; width: 26px;
margin-right: 5px; margin-right: 5px;
} }
.main-header__toggle-icon {
width: 20px;
transition: transform 300ms;
}
.main-header__toggle-icon--opened {
transform: rotate(180deg);
}

View file

@ -1,48 +1,115 @@
import React from 'react'; import React from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch, withRouter } from 'react-router-dom';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { compose } from 'redux';
import { pick } from 'ramda';
import Swipeable from 'react-swipeable';
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import * as PropTypes from 'prop-types';
import ShortUrlsVisits from '../short-urls/ShortUrlVisits';
import { selectServer } from '../servers/reducers/selectedServer'; import { selectServer } from '../servers/reducers/selectedServer';
import CreateShortUrl from '../short-urls/CreateShortUrl'; import CreateShortUrl from '../short-urls/CreateShortUrl';
import ShortUrls from '../short-urls/ShortUrls'; import ShortUrls from '../short-urls/ShortUrls';
import ShortUrlsVisits from '../short-urls/ShortUrlVisits'; import './MenuLayout.scss';
import TagsList from '../tags/TagsList';
import { serverType } from '../servers/prop-types';
import AsideMenu from './AsideMenu'; import AsideMenu from './AsideMenu';
import { pick } from 'ramda';
export class MenuLayout extends React.Component { const propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
};
export class MenuLayoutComponent extends React.Component {
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered // FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
/* eslint react/no-deprecated: "off" */
componentWillMount() { componentWillMount() {
const { serverId } = this.props.match.params; const { match, selectServer } = this.props;
this.props.selectServer(serverId); const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
} }
render() { render() {
const { selectedServer } = this.props; const { selectedServer } = this.props;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
return ( return (
<div className="row"> <React.Fragment>
<AsideMenu selectedServer={selectedServer} /> <FontAwesomeIcon
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"> icon={burgerIcon}
<Switch> className={burgerClasses}
<Route onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
exact />
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls} <Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={() => this.setState({ showSideBar: false })}
onSwipedRight={() => this.setState({ showSideBar: true })}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/> />
<Route <div
exact className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
path="/server/:serverId/create-short-url" onClick={() => this.setState({ showSideBar: false })}
component={CreateShortUrl} >
/> <Switch>
<Route <Route
exact exact
path="/server/:serverId/short-code/:shortCode/visits" path="/server/:serverId/list-short-urls/:page"
component={ShortUrlsVisits} component={ShortUrls}
/> />
</Switch> <Route
</div> exact
</div> path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlsVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
); );
} }
} }
export default connect(pick(['selectedServer', 'shortUrlsListParams']), { selectServer })(MenuLayout); MenuLayoutComponent.propTypes = propTypes;
const MenuLayout = compose(
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
withRouter
)(MenuLayoutComponent);
export default MenuLayout;

View file

@ -0,0 +1,34 @@
@import '../utils/base';
.menu-layout__swipeable {
$offset: 15px;
height: 100%;
margin-right: -$offset;
margin-left: -$offset;
padding-left: $offset;
padding-right: $offset;
}
.menu-layout__swipeable-inner {
height: 100%;
}
.menu-layout__burger-icon {
display: none;
transition: color 300ms;
position: fixed;
top: 18px;
z-index: 1035;
font-size: 1.5rem;
cursor: pointer;
color: rgba(255, 255, 255, .5);
@media (max-width: $smMax) {
display: inline-block;
}
}
.menu-layout__burger-icon--active {
color: white;
}

View file

@ -1,12 +1,24 @@
import React from 'react'; import React from 'react';
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
export class ScrollToTop extends React.Component { const propTypes = {
location: PropTypes.object,
window: PropTypes.shape({
scrollTo: PropTypes.func,
}),
children: PropTypes.node,
};
const defaultProps = {
window,
};
export class ScrollToTopComponent extends React.Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { location, window } = this.props; const { location, window } = this.props;
if (location !== prevProps.location) { if (location !== prevProps.location) {
window.scrollTo(0, 0) window.scrollTo(0, 0);
} }
} }
@ -15,8 +27,9 @@ export class ScrollToTop extends React.Component {
} }
} }
ScrollToTop.defaultProps = { ScrollToTopComponent.defaultProps = defaultProps;
window ScrollToTopComponent.propTypes = propTypes;
};
export default withRouter(ScrollToTop); const ScrollToTop = withRouter(ScrollToTopComponent);
export default ScrollToTop;

View file

@ -3,34 +3,29 @@
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: .25rem; border-radius: .25rem;
overflow: hidden; overflow: hidden;
min-height: calc(2.6rem + 2px); min-height: 2.6rem;
padding: 6px 0 0 6px; padding: 6px 0 0 6px;
} }
.react-tagsinput--focused { .react-tagsinput--focused {
border-color: #80bdff; border-color: #80bdff;
-webkit-box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); -webkit-box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
-webkit-transition: border-color .15s ease-in-out,-webkit-box-shadow .15s ease-in-out; -webkit-transition: border-color .15s ease-in-out, -webkit-box-shadow .15s ease-in-out;
transition: border-color .15s ease-in-out,-webkit-box-shadow .15s ease-in-out; -o-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
-o-transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out, -webkit-box-shadow .15s ease-in-out;
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-box-shadow .15s ease-in-out;
} }
.react-tagsinput-tag { .react-tagsinput-tag {
font-size: 1rem; font-size: 1rem;
background-color: #f1f1f1; background-color: #f1f1f1;
border-radius: 2px; border-radius: 4px;
border: 1px solid #d1d1d1;
display: inline-block; display: inline-block;
font-weight: 400; font-weight: 400;
margin: 0 5px 6px 0; margin: 0 5px 6px 0;
padding: 6px 8px; padding: 6px 8px;
line-height: 1; line-height: 1;
} color: #fff;
.react-tagsinput-tag:hover {
border-color: #b1b1b1;
} }
.react-tagsinput-remove { .react-tagsinput-remove {
@ -39,9 +34,9 @@
margin-left: 8px; margin-left: 8px;
} }
.react-tagsinput-tag a::before { .react-tagsinput-tag span:before {
content: "\2715"; content: "\2715";
color: #aaa; color: #fff;
} }
.react-tagsinput-input { .react-tagsinput-input {

View file

@ -5,15 +5,13 @@ import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux'; import { applyMiddleware, compose, createStore } from 'redux';
import ReduxThunk from 'redux-thunk'; import ReduxThunk from 'redux-thunk';
import '../node_modules/react-datepicker/dist/react-datepicker.css';
import './common/react-tagsinput.scss';
import App from './App'; import App from './App';
import './index.scss'; import './index.scss';
import ScrollToTop from './common/ScrollToTop' import ScrollToTop from './common/ScrollToTop';
import reducers from './reducers'; import reducers from './reducers';
import registerServiceWorker from './registerServiceWorker'; import registerServiceWorker from './registerServiceWorker';
import '../node_modules/react-datepicker/dist/react-datepicker.css';
import './common/react-tagsinput.scss';
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__

View file

@ -1,5 +1,11 @@
@import './utils/base'; @import './utils/base';
html,
body,
#root {
height: 100%
}
* { * {
outline: none !important; outline: none !important;
} }
@ -15,15 +21,16 @@
.dropdown-item { .dropdown-item {
cursor: pointer; cursor: pointer;
} }
.dropdown-item.active { .dropdown-item.active,
.dropdown-item:active {
@extend .bg-main; @extend .bg-main;
} }
.short-urls-container { .shlink-container {
padding: 20px 0; padding: 20px 0;
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
padding: 30px 30px 30px 20px; padding: 30px 15px;
} }
} }
@ -36,3 +43,10 @@
.react-datepicker-wrapper { .react-datepicker-wrapper {
display: block !important; display: block !important;
} }
.navbar-brand {
@media (max-width: $smMax) {
margin-right: auto !important; // This is needed to override a third party style
margin: 0 auto;
}
}

View file

@ -1,11 +1,14 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import serversReducer from '../servers/reducers/server'; import serversReducer from '../servers/reducers/server';
import selectedServerReducer from '../servers/reducers/selectedServer'; import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult'; import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult';
import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit';
export default combineReducers({ export default combineReducers({
servers: serversReducer, servers: serversReducer,
@ -13,5 +16,9 @@ export default combineReducers({
shortUrlsList: shortUrlsListReducer, shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer, shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationResultReducer, shortUrlCreationResult: shortUrlCreationResultReducer,
shortUrlVisits: shortUrlVisitsReducer shortUrlVisits: shortUrlVisitsReducer,
shortUrlTags: shortUrlTagsReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
tagEdit: tagEditReducer,
}); });

View file

@ -8,10 +8,14 @@
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior. // This link also includes instructions on opting out of this behavior.
/* eslint no-console: "off" */
const isLocalhost = Boolean( const isLocalhost = Boolean(
window.location.hostname === 'localhost' || window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address. // [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' || window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4. // 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match( window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
@ -22,6 +26,7 @@ export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location); const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin // Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to // from what our page is served on. This might happen if a CDN is used to
@ -55,9 +60,10 @@ export default function register() {
function registerValidSW(swUrl) { function registerValidSW(swUrl) {
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then(registration => { .then((registration) => {
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') { if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
@ -76,7 +82,7 @@ function registerValidSW(swUrl) {
}; };
}; };
}) })
.catch(error => { .catch((error) => {
console.error('Error during service worker registration:', error); console.error('Error during service worker registration:', error);
}); });
} }
@ -84,14 +90,16 @@ function registerValidSW(swUrl) {
function checkValidServiceWorker(swUrl) { function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl) fetch(swUrl)
.then(response => { .then((response) => {
const NOT_FOUND_STATUS = 404;
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
if ( if (
response.status === 404 || response.status === NOT_FOUND_STATUS ||
response.headers.get('content-type').indexOf('javascript') === -1 response.headers.get('content-type').indexOf('javascript') === -1
) { ) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload();
}); });
@ -110,7 +118,7 @@ function checkValidServiceWorker(swUrl) {
export function unregister() { export function unregister() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister(); registration.unregister();
}); });
} }

View file

@ -1,17 +1,41 @@
import { assoc, pick } from 'ramda'; import { assoc, dissoc, pick, pipe } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createServer } from './reducers/server';
import { resetSelectedServer } from './reducers/selectedServer';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { resetSelectedServer } from './reducers/selectedServer';
import { createServer } from './reducers/server';
import './CreateServer.scss'; import './CreateServer.scss';
import ImportServersBtn from './helpers/ImportServersBtn';
export class CreateServer extends React.Component { const SHOW_IMPORT_MSG_TIME = 4000;
const propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
resetSelectedServer: PropTypes.func,
};
export class CreateServerComponent extends React.Component {
state = { state = {
name: '', name: '',
url: '', url: '',
apiKey: '', apiKey: '',
serversImported: false,
};
handleSubmit = (e) => {
e.preventDefault();
const { createServer, history: { push } } = this.props;
const server = pipe(
assoc('id', uuid()),
dissoc('serversImported')
)(this.state);
createServer(server);
push(`/server/${server.id}/list-short-urls/1`);
}; };
componentDidMount() { componentDidMount() {
@ -19,15 +43,11 @@ export class CreateServer extends React.Component {
} }
render() { render() {
const submit = e => { const renderInputGroup = (id, placeholder, type = 'text') => (
e.preventDefault();
const server = assoc('id', uuid(), this.state);
this.props.createServer(server);
this.props.history.push(`/server/${server.id}/list-short-urls/1`)
};
const renderInputGroup = (id, placeholder, type = 'text') =>
<div className="form-group row"> <div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">{placeholder}:</label> <label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
{placeholder}:
</label>
<div className="col-lg-11 col-md-10"> <div className="col-lg-11 col-md-10">
<input <input
type={type} type={type}
@ -35,26 +55,50 @@ export class CreateServer extends React.Component {
id={id} id={id}
placeholder={placeholder} placeholder={placeholder}
value={this.state[id]} value={this.state[id]}
onChange={e => this.setState({ [id]: e.target.value })}
required required
onChange={(e) => this.setState({ [id]: e.target.value })}
/> />
</div> </div>
</div>; </div>
);
return ( return (
<div className="create-server"> <div className="create-server">
<form onSubmit={submit}> <form onSubmit={this.handleSubmit}>
{renderInputGroup('name', 'Name')} {renderInputGroup('name', 'Name')}
{renderInputGroup('url', 'URL', 'url')} {renderInputGroup('url', 'URL', 'url')}
{renderInputGroup('apiKey', 'API key')} {renderInputGroup('apiKey', 'API key')}
<div className="text-right"> <div className="text-right">
<button className="btn btn-primary btn-outline-primary">Create server</button> <ImportServersBtn
onImport={() => {
this.setState({ serversImported: true });
setTimeout(() => this.setState({ serversImported: false }), SHOW_IMPORT_MSG_TIME);
}}
/>
<button className="btn btn-outline-primary">Create server</button>
</div> </div>
{this.state.serversImported && (
<div className="row create-server__import-success-msg">
<div className="col-md-10 offset-md-1">
<div className="p-2 mt-3 bg-main text-white text-center">
Servers properly imported. You can now select one from the list :)
</div>
</div>
</div>
)}
</form> </form>
</div> </div>
); );
} }
} }
export default connect(pick(['selectedServer']), {createServer, resetSelectedServer })(CreateServer); CreateServerComponent.propTypes = propTypes;
const CreateServer = connect(
pick([ 'selectedServer' ]),
{ createServer, resetSelectedServer }
)(CreateServerComponent);
export default CreateServer;

View file

@ -12,3 +12,9 @@
text-align: right; text-align: right;
} }
} }
.create-server__csv-select {
position: absolute;
left: -9999px;
top: -9999px;
}

View file

@ -1,33 +1,41 @@
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle'; import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import DeleteServerModal from './DeleteServerModal'; import DeleteServerModal from './DeleteServerModal';
import { serverType } from './prop-types';
const propTypes = {
server: serverType,
className: PropTypes.string,
};
export default class DeleteServerButton extends React.Component { export default class DeleteServerButton extends React.Component {
state = { isModalOpen: false }; state = { isModalOpen: false };
render() { render() {
const { server } = this.props; const { server, className } = this.props;
return [ return (
( <React.Fragment>
<span <span
className={this.props.className} className={className}
onClick={() => this.setState({ isModalOpen: true })}
key="deleteServerBtn" key="deleteServerBtn"
onClick={() => this.setState({ isModalOpen: true })}
> >
<FontAwesomeIcon icon={deleteIcon} /> <FontAwesomeIcon icon={deleteIcon} />
<span className="aside-menu__item-text">Delete this server</span> <span className="aside-menu__item-text">Delete this server</span>
</span> </span>
),
(
<DeleteServerModal <DeleteServerModal
isOpen={this.state.isModalOpen} isOpen={this.state.isModalOpen}
toggle={() => this.setState({ isModalOpen: !this.state.isModalOpen })} toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
server={server} server={server}
key="deleteServerModal" key="deleteServerModal"
/> />
) </React.Fragment>
]; );
} }
} }
DeleteServerButton.propTypes = propTypes;

View file

@ -5,8 +5,19 @@ import { withRouter } from 'react-router-dom';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { compose } from 'redux'; import { compose } from 'redux';
import { deleteServer } from './reducers/server'; import { deleteServer } from './reducers/server';
import { serverType } from './prop-types';
export const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => { const propTypes = {
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
server: serverType,
deleteServer: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServer, history }) => {
const closeModal = () => { const closeModal = () => {
deleteServer(server); deleteServer(server);
toggle(); toggle();
@ -14,7 +25,7 @@ export const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, histor
}; };
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered={true}> <Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader> <ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
<ModalBody> <ModalBody>
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p> <p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
@ -31,18 +42,11 @@ export const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, histor
); );
}; };
DeleteServerModal.propTypes = { DeleteServerModalComponent.propTypes = propTypes;
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
server: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
}),
};
export default compose( const DeleteServerModal = compose(
withRouter, withRouter,
connect(null, { deleteServer }) connect(null, { deleteServer })
)(DeleteServerModal); )(DeleteServerModalComponent);
export default DeleteServerModal;

View file

@ -1,32 +1,59 @@
import { isEmpty, pick } from 'ramda'; import { isEmpty, pick, values } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types';
import { listServers } from './reducers/server';
import { selectServer } from '../servers/reducers/selectedServer'; import { selectServer } from '../servers/reducers/selectedServer';
import serversExporter from '../servers/services/ServersExporter';
import { listServers } from './reducers/server';
import { serverType } from './prop-types';
export class ServersDropdown extends React.Component { const defaultProps = {
serversExporter,
};
const propTypes = {
servers: PropTypes.object,
serversExporter: PropTypes.shape({
exportServers: PropTypes.func,
}),
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
};
export class ServersDropdownComponent extends React.Component {
renderServers = () => { renderServers = () => {
const { servers, selectedServer, selectServer } = this.props; const { servers, selectedServer, selectServer, serversExporter } = this.props;
if (isEmpty(servers)) { if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem> return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
} }
return Object.values(servers).map(({ name, id }) => ( return (
<span key={id}> <React.Fragment>
{values(servers).map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
active={selectedServer && selectedServer.id === id}
// FIXME This should be implicit
onClick={() => selectServer(id)}
>
{name}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem <DropdownItem
tag={Link} className="servers-dropdown__export-item"
to={`/server/${id}/list-short-urls/1`} onClick={() => serversExporter.exportServers()}
active={selectedServer && selectedServer.id === id}
onClick={() => selectServer(id)} // FIXME This should be implicit
> >
{name} Export servers
</DropdownItem> </DropdownItem>
</span> </React.Fragment>
)); );
}; };
componentDidMount() { componentDidMount() {
@ -35,12 +62,20 @@ export class ServersDropdown extends React.Component {
render() { render() {
return ( return (
<UncontrolledDropdown nav> <UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle> <DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu>{this.renderServers()}</DropdownMenu> <DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown> </UncontrolledDropdown>
); );
} }
} }
export default connect(pick(['servers', 'selectedServer']), { listServers, selectServer })(ServersDropdown); ServersDropdownComponent.defaultProps = defaultProps;
ServersDropdownComponent.propTypes = propTypes;
const ServersDropdown = connect(
pick([ 'servers', 'selectedServer' ]),
{ listServers, selectServer }
)(ServersDropdownComponent);
export default ServersDropdown;

View file

@ -0,0 +1,66 @@
import React from 'react';
import { connect } from 'react-redux';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc } from 'ramda';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { createServers } from '../reducers/server';
import serversImporter, { serversImporterType } from '../services/ServersImporter';
const defaultProps = {
serversImporter,
onImport: () => ({}),
};
const propTypes = {
onImport: PropTypes.func,
serversImporter: serversImporterType,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
export class ImportServersBtnComponent extends React.Component {
constructor(props) {
super(props);
this.fileRef = props.fileRef || React.createRef();
}
render() {
const { serversImporter: { importServersFromFile }, onImport, createServers } = this.props;
const onChange = (e) =>
importServersFromFile(e.target.files[0])
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
.then(createServers)
.then(onImport);
return (
<React.Fragment>
<button
type="button"
className="btn btn-outline-secondary mr-2"
id="importBtn"
onClick={() => this.fileRef.current.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>
</UncontrolledTooltip>
<input
type="file"
accept="text/csv"
className="create-server__csv-select"
ref={this.fileRef}
onChange={onChange}
/>
</React.Fragment>
);
}
}
ImportServersBtnComponent.defaultProps = defaultProps;
ImportServersBtnComponent.propTypes = propTypes;
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
export default ImportServersBtn;

View file

@ -0,0 +1,8 @@
import PropTypes from 'prop-types';
export const serverType = PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
});

View file

@ -1,10 +1,12 @@
import ShlinkApiClient from '../../api/ShlinkApiClient';
import ServersService from '../../servers/services/ServersService';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams'
import { curry } from 'ramda'; import { curry } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import serversService from '../../servers/services/ServersService';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER'; export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
/* eslint-enable padding-line-between-statements, newline-after-var */
const defaultState = null; const defaultState = null;
@ -21,15 +23,17 @@ export default function reducer(state = defaultState, action) {
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER }); export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
export const _selectServer = (ShlinkApiClient, ServersService, serverId) => dispatch => { export const _selectServer = (shlinkApiClient, serversService, serverId) => (dispatch) => {
dispatch(resetShortUrlParams()); dispatch(resetShortUrlParams());
const selectedServer = ServersService.findServerById(serverId); const selectedServer = serversService.findServerById(serverId);
ShlinkApiClient.setConfig(selectedServer);
shlinkApiClient.setConfig(selectedServer);
dispatch({ dispatch({
type: SELECT_SERVER, type: SELECT_SERVER,
selectedServer selectedServer,
}) });
}; };
export const selectServer = curry(_selectServer)(ShlinkApiClient, ServersService);
export const selectServer = curry(_selectServer)(shlinkApiClient, serversService);

View file

@ -1,37 +1,44 @@
import ServersService from '../services/ServersService';
import { curry } from 'ramda'; import { curry } from 'ramda';
import serversService from '../services/ServersService';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS'; export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
export const CREATE_SERVER = 'shlink/servers/CREATE_SERVER';
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export default function reducer(state = {}, action) { export default function reducer(state = {}, action) {
switch (action.type) { switch (action.type) {
case FETCH_SERVERS: case FETCH_SERVERS:
case DELETE_SERVER:
return action.servers; return action.servers;
case CREATE_SERVER:
const server = action.server;
return { ...state, [server.id]: server };
default: default:
return state; return state;
} }
} }
export const _listServers = ServersService => ({ export const _listServers = (serversService) => ({
type: FETCH_SERVERS, type: FETCH_SERVERS,
servers: ServersService.listServers(), servers: serversService.listServers(),
}); });
export const listServers = () => _listServers(ServersService);
export const _createServer = (ServersService, server) => { export const listServers = () => _listServers(serversService);
ServersService.createServer(server);
return _listServers(ServersService);
};
export const createServer = curry(_createServer)(ServersService);
export const _deleteServer = (ServersService, server) => { export const _createServer = (serversService, server) => {
ServersService.deleteServer(server); serversService.createServer(server);
return _listServers(ServersService);
return _listServers(serversService);
}; };
export const deleteServer = curry(_deleteServer)(ServersService);
export const createServer = curry(_createServer)(serversService);
export const _deleteServer = (serversService, server) => {
serversService.deleteServer(server);
return _listServers(serversService);
};
export const deleteServer = curry(_deleteServer)(serversService);
export const _createServers = (serversService, servers) => {
serversService.createServers(servers);
return _listServers(serversService);
};
export const createServers = curry(_createServers)(serversService);

View file

@ -0,0 +1,55 @@
import { dissoc, head, keys, values } from 'ramda';
import csvjson from 'csvjson';
import serversService from './ServersService';
const saveCsv = (window, csv) => {
const { navigator, document } = window;
const filename = 'shlink-servers.csv';
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
// IE10 and IE11
if (navigator.msSaveBlob) {
navigator.msSaveBlob(blob, filename);
return;
}
// Modern browsers
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export class ServersExporter {
constructor(serversService, window, csvjson) {
this.serversService = serversService;
this.window = window;
this.csvjson = csvjson;
}
exportServers = async () => {
const servers = values(this.serversService.listServers()).map(dissoc('id'));
try {
const csv = this.csvjson.toCSV(servers, {
headers: keys(head(servers)).join(','),
});
saveCsv(this.window, csv);
} catch (e) {
// FIXME Handle error
/* eslint no-console: "off" */
console.error(e);
}
};
}
const serverExporter = new ServersExporter(serversService, global.window, csvjson);
export default serverExporter;

View file

@ -0,0 +1,34 @@
import csvjson from 'csvjson';
import PropTypes from 'prop-types';
export const serversImporterType = PropTypes.shape({
importServersFromFile: PropTypes.func,
});
export class ServersImporter {
constructor(csvjson) {
this.csvjson = csvjson;
}
importServersFromFile = (file) => {
if (!file || file.type !== 'text/csv') {
return Promise.reject('No file provided or file is not a CSV');
}
const reader = new FileReader();
return new Promise((resolve) => {
reader.addEventListener('loadend', (e) => {
const content = e.target.result;
const servers = this.csvjson.toObject(content);
resolve(servers);
});
reader.readAsText(file);
});
};
}
const serversImporter = new ServersImporter(csvjson);
export default serversImporter;

View file

@ -1,5 +1,5 @@
import Storage from '../../utils/Storage'; import { assoc, dissoc, reduce } from 'ramda';
import { dissoc } from 'ramda'; import storage from '../../utils/Storage';
const SERVERS_STORAGE_KEY = 'servers'; const SERVERS_STORAGE_KEY = 'servers';
@ -8,25 +8,29 @@ export class ServersService {
this.storage = storage; this.storage = storage;
} }
listServers = () => { listServers = () => this.storage.get(SERVERS_STORAGE_KEY) || {};
return this.storage.get(SERVERS_STORAGE_KEY) || {};
findServerById = (serverId) => this.listServers()[serverId];
createServer = (server) => this.createServers([ server ]);
createServers = (servers) => {
const allServers = reduce(
(serversObj, server) => assoc(server.id, server, serversObj),
this.listServers(),
servers
);
this.storage.set(SERVERS_STORAGE_KEY, allServers);
}; };
findServerById = serverId => { deleteServer = (server) =>
const servers = this.listServers(); this.storage.set(
return servers[serverId]; SERVERS_STORAGE_KEY,
}; dissoc(server.id, this.listServers())
);
createServer = server => {
const servers = this.listServers();
servers[server.id] = server;
this.storage.set(SERVERS_STORAGE_KEY, servers);
};
deleteServer = server => {
const servers = dissoc(server.id, this.listServers());
this.storage.set(SERVERS_STORAGE_KEY, servers);
};
} }
export default new ServersService(Storage); const serversService = new ServersService(storage);
export default serversService;

View file

@ -4,13 +4,13 @@ import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda'; import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import TagsInput from 'react-tagsinput'
import { Collapse } from 'reactstrap'; import { Collapse } from 'reactstrap';
import DateInput from '../common/DateInput'; import DateInput from '../common/DateInput';
import TagsSelector from '../utils/TagsSelector';
import CreateShortUrlResult from './helpers/CreateShortUrlResult'; import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult'; import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult';
export class CreateShortUrl extends React.Component { export class CreateShortUrlComponent extends React.Component {
state = { state = {
longUrl: '', longUrl: '',
tags: [], tags: [],
@ -18,42 +18,44 @@ export class CreateShortUrl extends React.Component {
validSince: undefined, validSince: undefined,
validUntil: undefined, validUntil: undefined,
maxVisits: undefined, maxVisits: undefined,
moreOptionsVisible: false moreOptionsVisible: false,
}; };
render() { render() {
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props; const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
const changeTags = tags => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) }); const changeTags = (tags) => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) });
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<input <input
className="form-control" className="form-control"
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
value={this.state[id]} value={this.state[id]}
onChange={e => this.setState({ [id]: e.target.value })} onChange={(e) => this.setState({ [id]: e.target.value })}
{...props} {...props}
/>; />
const createDateInput = (id, placeholder, props = {}) => );
const createDateInput = (id, placeholder, props = {}) => (
<DateInput <DateInput
selected={this.state[id]} selected={this.state[id]}
placeholderText={placeholder} placeholderText={placeholder}
onChange={date => this.setState({ [id]: date })}
isClearable isClearable
onChange={(date) => this.setState({ [id]: date })}
{...props} {...props}
/>; />
const formatDate = date => isNil(date) ? date : date.format(); );
const save = e => { const formatDate = (date) => isNil(date) ? date : date.format();
const save = (e) => {
e.preventDefault(); e.preventDefault();
createShortUrl(pipe( createShortUrl(pipe(
dissoc('moreOptionsVisible'), // Remove moreOptionsVisible property dissoc('moreOptionsVisible'),
assoc('validSince', formatDate(this.state.validSince)), assoc('validSince', formatDate(this.state.validSince)),
assoc('validUntil', formatDate(this.state.validUntil)) assoc('validUntil', formatDate(this.state.validUntil))
)(this.state)); )(this.state));
}; };
return ( return (
<div className="short-urls-container"> <div className="shlink-container">
<form onSubmit={save}> <form onSubmit={save}>
<div className="form-group"> <div className="form-group">
<input <input
@ -62,19 +64,13 @@ export class CreateShortUrl extends React.Component {
placeholder="Insert the URL to be shortened" placeholder="Insert the URL to be shortened"
required required
value={this.state.longUrl} value={this.state.longUrl}
onChange={e => this.setState({ longUrl: e.target.value })} onChange={(e) => this.setState({ longUrl: e.target.value })}
/> />
</div> </div>
<Collapse isOpen={this.state.moreOptionsVisible}> <Collapse isOpen={this.state.moreOptionsVisible}>
<div className="form-group"> <div className="form-group">
<TagsInput <TagsSelector tags={this.state.tags} onChange={changeTags} />
value={this.state.tags}
onChange={changeTags}
inputProps={{ placeholder: 'Add tags to the URL' }}
onlyUnique
addOnBlur // FIXME Workaround to be able to add tags on Android
/>
</div> </div>
<div className="row"> <div className="row">
@ -101,7 +97,7 @@ export class CreateShortUrl extends React.Component {
<button <button
type="button" type="button"
className="btn btn-outline-secondary create-short-url__btn" className="btn btn-outline-secondary create-short-url__btn"
onClick={() => this.setState({ moreOptionsVisible: !this.state.moreOptionsVisible })} onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
> >
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} /> <FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
&nbsp; &nbsp;
@ -122,7 +118,9 @@ export class CreateShortUrl extends React.Component {
} }
} }
export default connect(pick(['shortUrlCreationResult']), { const CreateShortUrl = connect(pick([ 'shortUrlCreationResult' ]), {
createShortUrl, createShortUrl,
resetCreateShortUrl resetCreateShortUrl,
})(CreateShortUrl); })(CreateShortUrlComponent);
export default CreateShortUrl;

View file

@ -1,52 +1,55 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types';
import { range } from 'ramda';
export default class Paginator extends React.Component { const propTypes = {
render() { serverId: PropTypes.string.isRequired,
const { paginator = {}, serverId } = this.props; paginator: PropTypes.shape({
const { currentPage, pagesCount = 0 } = paginator; currentPage: PropTypes.number,
if (pagesCount <= 1) { pagesCount: PropTypes.number,
return null; }),
} };
const renderPages = () => { export default function Paginator({ paginator = {}, serverId }) {
const pages = []; const { currentPage, pagesCount = 0 } = paginator;
for (let i = 1; i <= pagesCount; i++) { if (pagesCount <= 1) {
pages.push( return null;
<PaginationItem key={i} active={currentPage === i}>
<PaginationLink
tag={Link}
to={`/server/${serverId}/list-short-urls/${i}`}
>
{i}
</PaginationLink>
</PaginationItem>
);
}
return pages;
};
return (
<Pagination listClassName="flex-wrap">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink
previous
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
/>
</PaginationItem>
{renderPages()}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink
next
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
/>
</PaginationItem>
</Pagination>
);
} }
const renderPages = () =>
range(1, pagesCount + 1).map((i) => (
<PaginationItem key={i} active={currentPage === i}>
<PaginationLink
tag={Link}
to={`/server/${serverId}/list-short-urls/${i}`}
>
{i}
</PaginationLink>
</PaginationItem>
));
return (
<Pagination listClassName="flex-wrap">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink
previous
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
/>
</PaginationItem>
{renderPages()}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink
next
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
/>
</PaginationItem>
</Pagination>
);
} }
Paginator.propTypes = propTypes;

View file

@ -1,82 +1,55 @@
import searchIcon from '@fortawesome/fontawesome-free-solid/faSearch';
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags'; import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { isEmpty, pick } from 'ramda';
import PropTypes from 'prop-types';
import Tag from '../utils/Tag'; import Tag from '../utils/Tag';
import SearchField from '../utils/SearchField';
import { listShortUrls } from './reducers/shortUrlsList'; import { listShortUrls } from './reducers/shortUrlsList';
import './SearchBar.scss'; import './SearchBar.scss';
import { pick, isEmpty } from 'ramda'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
export class SearchBar extends React.Component { const propTypes = {
state = { listShortUrls: PropTypes.func,
showClearBtn: false, shortUrlsListParams: shortUrlsListParamsType,
searchTerm: '', };
};
timer = null;
render() { export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
const { listShortUrls, shortUrlsListParams } = this.props; const selectedTags = shortUrlsListParams.tags || [];
const selectedTags = shortUrlsListParams.tags || [];
return ( return (
<div className="serach-bar-container"> <div className="serach-bar-container">
<div className="search-bar"> <SearchField onChange={
<input (searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
type="text" }
className="form-control form-control-lg search-bar__input" />
placeholder="Search..."
onChange={e => this.searchTermChanged(e.target.value)}
value={this.state.searchTerm}
/>
<FontAwesomeIcon icon={searchIcon} className="search-bar__icon" />
<div
className="close search-bar__close"
hidden={! this.state.showClearBtn}
onClick={() => this.searchTermChanged('')}
id="search-bar__close"
>
&times;
</div>
</div>
{!isEmpty(selectedTags) && ( {!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2"> <h4 className="search-bar__selected-tag mt-2">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" /> <FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp; &nbsp;
{selectedTags.map(tag => <Tag {selectedTags.map((tag) => (
<Tag
key={tag}
text={tag} text={tag}
clearable clearable
onClose={() => listShortUrls( onClose={() => listShortUrls(
{ {
...shortUrlsListParams, ...shortUrlsListParams,
tags: selectedTags.filter(selectedTag => selectedTag !== tag) tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
} }
)} )}
/>)} />
</h4> ))}
)} </h4>
</div> )}
); </div>
} );
searchTermChanged(searchTerm) {
this.setState({
showClearBtn: searchTerm !== '',
searchTerm: searchTerm,
});
const resetTimer = () => {
clearTimeout(this.timer);
this.timer = null;
};
resetTimer();
this.timer = setTimeout(() => {
this.props.listShortUrls({ ...this.props.shortUrlsListParams, searchTerm });
resetTimer();
}, 500);
}
} }
export default connect(pick(['shortUrlsListParams']), { listShortUrls })(SearchBar); SearchBarComponent.propTypes = propTypes;
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent);
export default SearchBar;

View file

@ -1,25 +1,3 @@
@import '../utils/mixins/vertical-align';
.search-bar {
position: relative;
}
.search-bar__input.search-bar__input {
padding-left: 40px;
padding-right: 40px;
}
.search-bar__icon {
@include vertical-align();
left: 15px;
color: #707581;
}
.search-bar__close {
@include vertical-align();
right: 15px;
}
.search-bar__tags-icon { .search-bar__tags-icon {
vertical-align: bottom; vertical-align: bottom;
} }

View file

@ -1,33 +1,51 @@
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch' import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome' import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, pick } from 'ramda' import { isEmpty, mapObjIndexed, pick } from 'ramda';
import React from 'react' import React from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2' import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import Moment from 'react-moment' import Moment from 'react-moment';
import { connect } from 'react-redux' import { connect } from 'react-redux';
import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap' import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap';
import DateInput from '../common/DateInput' import PropTypes from 'prop-types';
import VisitsParser from '../visits/services/VisitsParser' import DateInput from '../common/DateInput';
import { getShortUrlVisits } from './reducers/shortUrlVisits' import {
import './ShortUrlVisits.scss' processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
} from '../visits/services/VisitsParser';
import MutedMessage from '../utils/MuttedMessage';
import ExternalLink from '../utils/ExternalLink';
import { serverType } from '../servers/prop-types';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
import './ShortUrlVisits.scss';
const MutedMessage = ({ children }) => const propTypes = {
<div className="col-md-10 offset-md-1"> processOsStats: PropTypes.func,
<Card className="bg-light mt-4" body> processBrowserStats: PropTypes.func,
<h3 className="text-center text-muted mb-0"> processCountriesStats: PropTypes.func,
{children} processReferrersStats: PropTypes.func,
</h3> match: PropTypes.object,
</Card> getShortUrlVisits: PropTypes.func,
</div>; selectedServer: serverType,
shortUrlVisits: shortUrlVisitsType,
};
const defaultProps = {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
};
export class ShortUrlsVisits extends React.Component { export class ShortUrlsVisitsComponent extends React.Component {
state = { startDate: undefined, endDate: undefined }; state = { startDate: undefined, endDate: undefined };
loadVisits = () => { loadVisits = () => {
const { match: { params } } = this.props; const { match: { params }, getShortUrlVisits } = this.props;
this.props.getShortUrlVisits(params.shortCode, mapObjIndexed(
value => value && value.format ? value.format('YYYY-MM-DD') : value, getShortUrlVisits(params.shortCode, mapObjIndexed(
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state this.state
)) ));
}; };
componentDidMount() { componentDidMount() {
@ -38,8 +56,11 @@ export class ShortUrlsVisits extends React.Component {
const { const {
match: { params }, match: { params },
selectedServer, selectedServer,
visitsParser, processOsStats,
shortUrlVisits: { visits, loading, error, shortUrl } processBrowserStats,
processCountriesStats,
processReferrersStats,
shortUrlVisits: { visits, loading, error, shortUrl },
} = this.props; } = this.props;
const serverUrl = selectedServer ? selectedServer.url : ''; const serverUrl = selectedServer ? selectedServer.url : '';
const shortLink = `${serverUrl}/${params.shortCode}`; const shortLink = `${serverUrl}/${params.shortCode}`;
@ -56,31 +77,42 @@ export class ShortUrlsVisits extends React.Component {
'#46BFBD', '#46BFBD',
'#FDB45C', '#FDB45C',
'#949FB1', '#949FB1',
'#4D5360' '#4D5360',
], ],
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white', borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
borderWidth: 2 borderWidth: 2,
} },
] ],
}); });
const renderGraphCard = (title, stats, isBarChart, label) => const renderGraphCard = (title, stats, isBarChart, label) => (
<div className="col-md-6"> <div className="col-md-6">
<Card className="mt-4"> <Card className="mt-4">
<CardHeader>{title}</CardHeader> <CardHeader>{title}</CardHeader>
<CardBody> <CardBody>
{!isBarChart && <Doughnut data={generateGraphData(stats, label || title, isBarChart)} options={{ {!isBarChart && (
legend: { <Doughnut
position: 'right' data={generateGraphData(stats, label || title, isBarChart)}
} options={{
}} />} legend: {
{isBarChart && <HorizontalBar data={generateGraphData(stats, label || title, isBarChart)} options={{ position: 'right',
legend: { },
display: false }}
} />
}} />} )}
{isBarChart && (
<HorizontalBar
data={generateGraphData(stats, label || title, isBarChart)}
options={{
legend: {
display: false,
},
}}
/>
)}
</CardBody> </CardBody>
</Card> </Card>
</div>; </div>
);
const renderContent = () => { const renderContent = () => {
if (loading) { if (loading) {
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>; return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
@ -100,23 +132,25 @@ export class ShortUrlsVisits extends React.Component {
return ( return (
<div className="row"> <div className="row">
{renderGraphCard('Operating systems', visitsParser.processOsStats(visits), false)} {renderGraphCard('Operating systems', processOsStats(visits), false)}
{renderGraphCard('Browsers', visitsParser.processBrowserStats(visits), false)} {renderGraphCard('Browsers', processBrowserStats(visits), false)}
{renderGraphCard('Countries', visitsParser.processCountriesStats(visits), true, 'Visits')} {renderGraphCard('Countries', processCountriesStats(visits), true, 'Visits')}
{renderGraphCard('Referrers', visitsParser.processReferrersStats(visits), true, 'Visits')} {renderGraphCard('Referrers', processReferrersStats(visits), true, 'Visits')}
</div> </div>
); );
}; };
const renderCreated = () => const renderCreated = () => (
<span> <span>
<b id="created"><Moment fromNow>{shortUrl.dateCreated}</Moment></b> <b id="created"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
<UncontrolledTooltip placement="bottom" target="created"> <UncontrolledTooltip placement="bottom" target="created">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment> <Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</UncontrolledTooltip> </UncontrolledTooltip>
</span>; </span>
);
return ( return (
<div className="short-urls-container"> <div className="shlink-container">
<header> <header>
<Card className="bg-light"> <Card className="bg-light">
<CardBody> <CardBody>
@ -125,20 +159,22 @@ export class ShortUrlsVisits extends React.Component {
shortUrl.visitsCount && shortUrl.visitsCount &&
<span className="badge badge-main float-right">Visits: {shortUrl.visitsCount}</span> <span className="badge badge-main float-right">Visits: {shortUrl.visitsCount}</span>
} }
Visit stats for <a target="_blank" href={shortLink}>{shortLink}</a> Visit stats for <ExternalLink href={shortLink}>{shortLink}</ExternalLink>
</h2> </h2>
<hr /> <hr />
{shortUrl.dateCreated && <div> {shortUrl.dateCreated && (
Created: <div>
&nbsp; Created:
{loading && <small>Loading...</small>} &nbsp;
{!loading && renderCreated()} {loading && <small>Loading...</small>}
</div>} {!loading && renderCreated()}
</div>
)}
<div> <div>
Long URL: Long URL:
&nbsp; &nbsp;
{loading && <small>Loading...</small>} {loading && <small>Loading...</small>}
{!loading && <a target="_blank" href={shortUrl.longUrl}>{shortUrl.longUrl}</a>} {!loading && <ExternalLink href={shortUrl.longUrl}>{shortUrl.longUrl}</ExternalLink>}
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
@ -151,7 +187,7 @@ export class ShortUrlsVisits extends React.Component {
selected={this.state.startDate} selected={this.state.startDate}
placeholderText="Since" placeholderText="Since"
isClearable isClearable
onChange={date => this.setState({ startDate: date }, () => this.loadVisits())} onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())}
/> />
</div> </div>
<div className="col-xl-3 col-lg-4 col-md-6"> <div className="col-xl-3 col-lg-4 col-md-6">
@ -159,8 +195,8 @@ export class ShortUrlsVisits extends React.Component {
selected={this.state.endDate} selected={this.state.endDate}
placeholderText="Until" placeholderText="Until"
isClearable isClearable
onChange={date => this.setState({ endDate: date }, () => this.loadVisits())}
className="short-url-visits__date-input" className="short-url-visits__date-input"
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
/> />
</div> </div>
</div> </div>
@ -174,8 +210,12 @@ export class ShortUrlsVisits extends React.Component {
} }
} }
ShortUrlsVisits.defaultProps = { ShortUrlsVisitsComponent.propTypes = propTypes;
visitsParser: VisitsParser ShortUrlsVisitsComponent.defaultProps = defaultProps;
};
export default connect(pick(['selectedServer', 'shortUrlVisits']), { getShortUrlVisits })(ShortUrlsVisits); const ShortUrlsVisits = connect(
pick([ 'selectedServer', 'shortUrlVisits' ]),
{ getShortUrlVisits }
)(ShortUrlsVisitsComponent);
export default ShortUrlsVisits;

View file

@ -1,7 +1,7 @@
@import '../utils/base'; @import '../utils/base';
.short-url-visits__date-input { .short-url-visits__date-input {
@media(max-width: $smMax) { @media (max-width: $smMax) {
margin-top: 0.5rem; margin-top: .5rem;
} }
} }

View file

@ -1,17 +1,18 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { assoc } from 'ramda';
import Paginator from './Paginator'; import Paginator from './Paginator';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import ShortUrlsList from './ShortUrlsList'; import ShortUrlsList from './ShortUrlsList';
import { assoc } from 'ramda';
export function ShortUrls(props) { export function ShortUrlsComponent(props) {
const { match: { params } } = props; const { match: { params } } = props;
// Using a key on a component makes react to create a new instance every time the key changes // Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${params.serverId}_${params.page}`; const urlsListKey = `${params.serverId}_${params.page}`;
return ( return (
<div className="short-urls-container"> <div className="shlink-container">
<div className="form-group"><SearchBar /></div> <div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} /> <ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} />
<Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} /> <Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} />
@ -19,4 +20,8 @@ export function ShortUrls(props) {
); );
} }
export default connect(state => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList))(ShortUrls); const ShortUrls = connect(
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
)(ShortUrlsComponent);
export default ShortUrls;

View file

@ -1,13 +1,17 @@
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown' import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp' import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome' import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { head, isEmpty, pick, toPairs } from 'ramda' import { head, isEmpty, pick, toPairs, keys, values } from 'ramda';
import React from 'react' import React from 'react';
import { connect } from 'react-redux' import { connect } from 'react-redux';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap' import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { ShortUrlsRow } from './helpers/ShortUrlsRow' import qs from 'qs';
import { listShortUrls } from './reducers/shortUrlsList' import PropTypes from 'prop-types';
import './ShortUrlsList.scss' import { serverType } from '../servers/prop-types';
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList';
import './ShortUrlsList.scss';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
const SORTABLE_FIELDS = { const SORTABLE_FIELDS = {
dateCreated: 'Created at', dateCreated: 'Created at',
@ -16,29 +20,43 @@ const SORTABLE_FIELDS = {
visits: 'Visits', visits: 'Visits',
}; };
export class ShortUrlsList extends React.Component { const propTypes = {
refreshList = extraParams => { listShortUrls: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
match: PropTypes.object,
location: PropTypes.object,
loading: PropTypes.bool,
error: PropTypes.bool,
shortUrlsList: PropTypes.arrayOf(shortUrlType),
selectedServer: serverType,
};
export class ShortUrlsListComponent extends React.Component {
refreshList = (extraParams) => {
const { listShortUrls, shortUrlsListParams } = this.props; const { listShortUrls, shortUrlsListParams } = this.props;
listShortUrls({ listShortUrls({
...shortUrlsListParams, ...shortUrlsListParams,
...extraParams ...extraParams,
}); });
}; };
determineOrderDir = field => { determineOrderDir = (field) => {
if (this.state.orderField !== field) { if (this.state.orderField !== field) {
return 'ASC'; return 'ASC';
} }
const newOrderMap = { const newOrderMap = {
'ASC': 'DESC', ASC: 'DESC',
'DESC': undefined, DESC: undefined,
}; };
return this.state.orderDir ? newOrderMap[this.state.orderDir] : 'ASC'; return this.state.orderDir ? newOrderMap[this.state.orderDir] : 'ASC';
} };
orderBy = field => { orderBy = (field) => {
const newOrderDir = this.determineOrderDir(field); const newOrderDir = this.determineOrderDir(field);
this.setState({ orderField: newOrderDir !== undefined ? field : undefined, orderDir: newOrderDir }); this.setState({ orderField: newOrderDir !== undefined ? field : undefined, orderDir: newOrderDir });
this.refreshList({ orderBy: { [field]: newOrderDir } }) this.refreshList({ orderBy: { [field]: newOrderDir } });
}; };
renderOrderIcon = (field, className = 'short-urls-list__header-icon') => { renderOrderIcon = (field, className = 'short-urls-list__header-icon') => {
if (this.state.orderField !== field) { if (this.state.orderField !== field) {
@ -57,32 +75,40 @@ export class ShortUrlsList extends React.Component {
super(props); super(props);
const { orderBy } = props.shortUrlsListParams; const { orderBy } = props.shortUrlsListParams;
this.state = { this.state = {
orderField: orderBy ? head(Object.keys(orderBy)) : undefined, orderField: orderBy ? head(keys(orderBy)) : undefined,
orderDir: orderBy ? head(Object.values(orderBy)) : undefined, orderDir: orderBy ? head(values(orderBy)) : undefined,
} };
} }
componentDidMount() { componentDidMount() {
const { match: { params } } = this.props; const { match: { params }, location } = this.props;
this.refreshList({ page: params.page }); const query = qs.parse(location.search, { ignoreQueryPrefix: true });
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : [] });
} }
renderShortUrls() { renderShortUrls() {
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props; const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
if (error) { if (error) {
return <tr><td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td></tr>; return (
<tr>
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
} }
if (loading) { if (loading) {
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>; return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
} }
if (! loading && isEmpty(shortUrlsList)) { if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan="6" className="text-center">No results found</td></tr>; return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
} }
return shortUrlsList.map(shortUrl => ( return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow <ShortUrlsRow
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
@ -101,11 +127,12 @@ export class ShortUrlsList extends React.Component {
Order by Order by
</DropdownToggle> </DropdownToggle>
<DropdownMenu className="short-urls-list__order-dropdown"> <DropdownMenu className="short-urls-list__order-dropdown">
{toPairs(SORTABLE_FIELDS).map(([key, value]) => {toPairs(SORTABLE_FIELDS).map(([ key, value ]) => (
<DropdownItem active={this.state.orderField === key} onClick={() => this.orderBy(key)}> <DropdownItem key={key} active={this.state.orderField === key} onClick={() => this.orderBy(key)}>
{value} {value}
{this.renderOrderIcon(key, 'short-urls-list__header-icon--mobile')} {this.renderOrderIcon(key, 'short-urls-list__header-icon--mobile')}
</DropdownItem>)} </DropdownItem>
))}
</DropdownMenu> </DropdownMenu>
</UncontrolledDropdown> </UncontrolledDropdown>
</div> </div>
@ -159,4 +186,11 @@ export class ShortUrlsList extends React.Component {
} }
} }
export default connect(pick(['selectedServer', 'shortUrlsListParams']), { listShortUrls })(ShortUrlsList); ShortUrlsListComponent.propTypes = propTypes;
const ShortUrlsList = connect(
pick([ 'selectedServer', 'shortUrlsListParams' ]),
{ listShortUrls }
)(ShortUrlsListComponent);
export default ShortUrlsList;

View file

@ -3,8 +3,18 @@ import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda'; import { isNil } from 'ramda';
import React from 'react'; import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
import './CreateShortUrlResult.scss'
import { Card, CardBody, Tooltip } from 'reactstrap'; import { Card, CardBody, Tooltip } from 'reactstrap';
import PropTypes from 'prop-types';
import { createShortUrlResultType } from '../reducers/shortUrlCreationResult';
import './CreateShortUrlResult.scss';
const TIME_TO_SHOW_COPY_TOOLTIP = 2000;
const propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
result: createShortUrlResultType,
};
export default class CreateShortUrlResult extends React.Component { export default class CreateShortUrlResult extends React.Component {
state = { showCopyTooltip: false }; state = { showCopyTooltip: false };
@ -30,7 +40,7 @@ export default class CreateShortUrlResult extends React.Component {
const { shortUrl } = result; const { shortUrl } = result;
const onCopy = () => { const onCopy = () => {
this.setState({ showCopyTooltip: true }); this.setState({ showCopyTooltip: true });
setTimeout(() => this.setState({ showCopyTooltip: false }), 2000); setTimeout(() => this.setState({ showCopyTooltip: false }), TIME_TO_SHOW_COPY_TOOLTIP);
}; };
return ( return (
@ -39,8 +49,12 @@ export default class CreateShortUrlResult extends React.Component {
<b>Great!</b> The short URL is <b>{shortUrl}</b> <b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={onCopy}> <CopyToClipboard text={shortUrl} onCopy={onCopy}>
<button className="btn btn-light btn-sm create-short-url-result__copy-btn" id="copyBtn" type="button"> <button
<FontAwesomeIcon icon={copyIcon}/> Copy className="btn btn-light btn-sm create-short-url-result__copy-btn"
id="copyBtn"
type="button"
>
<FontAwesomeIcon icon={copyIcon} /> Copy
</button> </button>
</CopyToClipboard> </CopyToClipboard>
@ -51,4 +65,6 @@ export default class CreateShortUrlResult extends React.Component {
</Card> </Card>
); );
} }
}; }
CreateShortUrlResult.propTypes = propTypes;

View file

@ -0,0 +1,100 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import TagsSelector from '../../utils/TagsSelector';
import {
editShortUrlTags,
resetShortUrlsTags,
shortUrlTagsType,
shortUrlTagsEdited,
} from '../reducers/shortUrlTags';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
url: PropTypes.string.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func,
shortUrlTagsEdited: PropTypes.func,
resetShortUrlsTags: PropTypes.func,
};
export class EditTagsModalComponent extends React.Component {
saveTags = () => {
const { editShortUrlTags, shortUrl, toggle } = this.props;
editShortUrlTags(shortUrl.shortCode, this.state.tags)
.then(() => {
this.tagsSaved = true;
toggle();
})
.catch(() => {});
};
refreshShortUrls = () => {
if (!this.tagsSaved) {
return;
}
const { shortUrlTagsEdited, shortUrl } = this.props;
const { tags } = this.state;
shortUrlTagsEdited(shortUrl.shortCode, tags);
};
componentDidMount() {
const { resetShortUrlsTags } = this.props;
resetShortUrlsTags();
this.tagsSaved = false;
}
constructor(props) {
super(props);
this.state = { tags: props.shortUrl.tags };
}
render() {
const { isOpen, toggle, url, shortUrlTags } = this.props;
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
<ModalHeader toggle={toggle}>
Edit tags for <ExternalLink href={url}>{url}</ExternalLink>
</ModalHeader>
<ModalBody>
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
{shortUrlTags.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the tags :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button
className="btn btn-primary"
type="button"
disabled={shortUrlTags.saving}
onClick={() => this.saveTags()}
>
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
</button>
</ModalFooter>
</Modal>
);
}
}
EditTagsModalComponent.propTypes = propTypes;
const EditTagsModal = connect(
pick([ 'shortUrlTags' ]),
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }
)(EditTagsModalComponent);
export default EditTagsModal;

View file

@ -1,11 +1,21 @@
import React from 'react' import React from 'react';
import { Modal, ModalBody, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import './PreviewModal.scss'; import './PreviewModal.scss';
import ExternalLink from '../../utils/ExternalLink';
export default function PreviewModal ({ url, toggle, isOpen }) { const propTypes = {
url: PropTypes.string,
toggle: PropTypes.func,
isOpen: PropTypes.bool,
};
export default function PreviewModal({ url, toggle, isOpen }) {
return ( return (
<Modal isOpen={isOpen} toggle={toggle} size="lg"> <Modal isOpen={isOpen} toggle={toggle} size="lg">
<ModalHeader toggle={toggle}>Preview for <a target="_blank" href={url}>{url}</a></ModalHeader> <ModalHeader toggle={toggle}>
Preview for <ExternalLink href={url}>{url}</ExternalLink>
</ModalHeader>
<ModalBody> <ModalBody>
<div className="text-center"> <div className="text-center">
<p className="preview-modal__loader">Loading...</p> <p className="preview-modal__loader">Loading...</p>
@ -15,3 +25,5 @@ export default function PreviewModal ({ url, toggle, isOpen }) {
</Modal> </Modal>
); );
} }
PreviewModal.propTypes = propTypes;

View file

@ -1,11 +1,21 @@
import React from 'react' import React from 'react';
import { Modal, ModalBody, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import './QrCodeModal.scss'; import './QrCodeModal.scss';
import ExternalLink from '../../utils/ExternalLink';
export default function QrCodeModal ({ url, toggle, isOpen }) { const propTypes = {
url: PropTypes.string,
toggle: PropTypes.func,
isOpen: PropTypes.bool,
};
export default function QrCodeModal({ url, toggle, isOpen }) {
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered={true}> <Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>QR code for <a target="_blank" href={url}>{url}</a></ModalHeader> <ModalHeader toggle={toggle}>
QR code for <ExternalLink href={url}>{url}</ExternalLink>
</ModalHeader>
<ModalBody> <ModalBody>
<div className="text-center"> <div className="text-center">
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" /> <img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
@ -14,3 +24,5 @@ export default function QrCodeModal ({ url, toggle, isOpen }) {
</Modal> </Modal>
); );
} }
QrCodeModal.propTypes = propTypes;

View file

@ -1,9 +1,23 @@
import { isEmpty } from 'ramda'; import { isEmpty } from 'ramda';
import React from 'react'; import React from 'react';
import Moment from 'react-moment'; import Moment from 'react-moment';
import PropTypes from 'prop-types';
import Tag from '../../utils/Tag'; import Tag from '../../utils/Tag';
import './ShortUrlsRow.scss'; import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu'; import { ShortUrlsRowMenu } from './ShortUrlsRowMenu';
import './ShortUrlsRow.scss';
const COPIED_MSG_TIME = 2000;
const propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType,
shortUrl: shortUrlType,
};
export class ShortUrlsRow extends React.Component { export class ShortUrlsRow extends React.Component {
state = { copiedToClipboard: false }; state = { copiedToClipboard: false };
@ -15,9 +29,14 @@ export class ShortUrlsRow extends React.Component {
const { refreshList, shortUrlsListParams } = this.props; const { refreshList, shortUrlsListParams } = this.props;
const selectedTags = shortUrlsListParams.tags || []; const selectedTags = shortUrlsListParams.tags || [];
return tags.map(
tag => <Tag key={tag} text={tag} onClick={() => refreshList({tags: [ ...selectedTags, tag ] })} /> return tags.map((tag) => (
); <Tag
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
/>
));
} }
render() { render() {
@ -30,10 +49,10 @@ export class ShortUrlsRow extends React.Component {
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment> <Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td> </td>
<td className="short-urls-row__cell" data-th="Short URL: "> <td className="short-urls-row__cell" data-th="Short URL: ">
<a href={completeShortUrl} target="_blank">{completeShortUrl}</a> <ExternalLink href={completeShortUrl}>{completeShortUrl}</ExternalLink>
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: "> <td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<a href={shortUrl.originalUrl} target="_blank">{shortUrl.originalUrl}</a> <ExternalLink href={shortUrl.originalUrl}>{shortUrl.originalUrl}</ExternalLink>
</td> </td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</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> <td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td>
@ -45,12 +64,12 @@ export class ShortUrlsRow extends React.Component {
Copied short URL! Copied short URL!
</small> </small>
<ShortUrlsRowMenu <ShortUrlsRowMenu
shortUrl={completeShortUrl} completeShortUrl={completeShortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
shortCode={shortUrl.shortCode} shortUrl={shortUrl}
onCopyToClipboard={() => { onCopyToClipboard={() => {
this.setState({ copiedToClipboard: true }); this.setState({ copiedToClipboard: true });
setTimeout(() => this.setState({ copiedToClipboard: false }), 2000); setTimeout(() => this.setState({ copiedToClipboard: false }), COPIED_MSG_TIME);
}} }}
/> />
</td> </td>
@ -58,3 +77,5 @@ export class ShortUrlsRow extends React.Component {
); );
} }
} }
ShortUrlsRow.propTypes = propTypes;

View file

@ -1,5 +1,6 @@
import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy'; import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy';
import pictureIcon from '@fortawesome/fontawesome-free-regular/faImage'; 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 pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie';
import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV'; import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV';
import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode'; import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode';
@ -8,55 +9,81 @@ import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal'; import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal'; import QrCodeModal from './QrCodeModal';
import './ShortUrlsRowMenu.scss'; import './ShortUrlsRowMenu.scss';
import EditTagsModal from './EditTagsModal';
const propTypes = {
completeShortUrl: PropTypes.string,
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
shortUrl: shortUrlType,
};
export class ShortUrlsRowMenu extends React.Component { export class ShortUrlsRowMenu extends React.Component {
state = { isOpen: false, isQrModalOpen: false, isPreviewOpen: false }; state = {
toggle = () => this.setState({ isOpen: !this.state.isOpen }); isOpen: false,
isQrModalOpen: false,
isPreviewOpen: false,
isTagsModalOpen: false,
};
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() { render() {
const { shortUrl, onCopyToClipboard, selectedServer, shortCode } = this.props; const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props;
const serverId = selectedServer ? selectedServer.id : ''; const serverId = selectedServer ? selectedServer.id : '';
const toggleQrCode = () => this.setState({isQrModalOpen: !this.state.isQrModalOpen}); const toggleQrCode = () => this.setState(({ isQrModalOpen }) => ({ isQrModalOpen: !isQrModalOpen }));
const togglePreview = () => this.setState({isPreviewOpen: !this.state.isPreviewOpen}); const togglePreview = () => this.setState(({ isPreviewOpen }) => ({ isPreviewOpen: !isPreviewOpen }));
const toggleTags = () => this.setState(({ isTagsModalOpen }) => ({ isTagsModalOpen: !isTagsModalOpen }));
return ( return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen} direction="left"> <ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen} direction="left">
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary"> <DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
&nbsp;<FontAwesomeIcon icon={menuIcon}/>&nbsp; &nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle> </DropdownToggle>
<DropdownMenu> <DropdownMenu>
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortCode}/visits`}> <DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon}/> &nbsp;Visit Stats <FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit Stats
</DropdownItem> </DropdownItem>
<DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} /> &nbsp;Edit tags
</DropdownItem>
<EditTagsModal
url={completeShortUrl}
shortUrl={shortUrl}
isOpen={this.state.isTagsModalOpen}
toggle={toggleTags}
/>
<DropdownItem divider/> <DropdownItem divider />
<DropdownItem onClick={togglePreview}> <DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon}/> &nbsp;Preview <FontAwesomeIcon icon={pictureIcon} /> &nbsp;Preview
</DropdownItem> </DropdownItem>
<PreviewModal <PreviewModal
url={shortUrl} url={completeShortUrl}
isOpen={this.state.isPreviewOpen} isOpen={this.state.isPreviewOpen}
toggle={togglePreview} toggle={togglePreview}
/> />
<DropdownItem onClick={toggleQrCode}> <DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon}/> &nbsp;QR code <FontAwesomeIcon icon={qrIcon} /> &nbsp;QR code
</DropdownItem> </DropdownItem>
<QrCodeModal <QrCodeModal
url={shortUrl} url={completeShortUrl}
isOpen={this.state.isQrModalOpen} isOpen={this.state.isQrModalOpen}
toggle={toggleQrCode} toggle={toggleQrCode}
/> />
<DropdownItem divider/> <DropdownItem divider />
<CopyToClipboard text={shortUrl} onCopy={onCopyToClipboard}> <CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
<DropdownItem> <DropdownItem>
<FontAwesomeIcon icon={copyIcon}/> &nbsp;Copy to clipboard <FontAwesomeIcon icon={copyIcon} /> &nbsp;Copy to clipboard
</DropdownItem> </DropdownItem>
</CopyToClipboard> </CopyToClipboard>
</DropdownMenu> </DropdownMenu>
@ -64,3 +91,5 @@ export class ShortUrlsRowMenu extends React.Component {
); );
} }
} }
ShortUrlsRowMenu.propTypes = propTypes;

View file

@ -1,10 +1,21 @@
import ShlinkApiClient from '../../api/ShlinkApiClient';
import { curry } from 'ramda'; import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR'; const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL'; const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const createShortUrlResultType = PropTypes.shape({
result: PropTypes.shape({
shortUrl: PropTypes.string,
}),
saving: PropTypes.bool,
error: PropTypes.bool,
});
const defaultState = { const defaultState = {
result: null, result: null,
@ -38,16 +49,18 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _createShortUrl = (ShlinkApiClient, data) => async dispatch => { export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => {
dispatch({ type: CREATE_SHORT_URL_START }); dispatch({ type: CREATE_SHORT_URL_START });
try { try {
const result = await ShlinkApiClient.createShortUrl(data); const result = await shlinkApiClient.createShortUrl(data);
dispatch({ type: CREATE_SHORT_URL, result }); dispatch({ type: CREATE_SHORT_URL, result });
} catch (e) { } catch (e) {
dispatch({ type: CREATE_SHORT_URL_ERROR }); dispatch({ type: CREATE_SHORT_URL_ERROR });
} }
}; };
export const createShortUrl = curry(_createShortUrl)(ShlinkApiClient);
export const createShortUrl = curry(_createShortUrl)(shlinkApiClient);
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL }); export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });

View file

@ -0,0 +1,77 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlTagsType = PropTypes.shape({
shortCode: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
saving: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
});
const defaultState = {
shortCode: null,
tags: [],
saving: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case EDIT_SHORT_URL_TAGS_START:
return {
...state,
saving: true,
error: false,
};
case EDIT_SHORT_URL_TAGS_ERROR:
return {
...state,
saving: false,
error: true,
};
case EDIT_SHORT_URL_TAGS:
return {
shortCode: action.shortCode,
tags: action.tags,
saving: false,
error: false,
};
case RESET_EDIT_SHORT_URL_TAGS:
return defaultState;
default:
return state;
}
}
export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (dispatch) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
try {
// Update short URL tags
await shlinkApiClient.updateShortUrlTags(shortCode, tags);
dispatch({ tags, shortCode, type: EDIT_SHORT_URL_TAGS });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
throw e;
}
};
export const editShortUrlTags = curry(_editShortUrlTags)(shlinkApiClient);
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
export const shortUrlTagsEdited = (shortCode, tags) => ({
tags,
shortCode,
type: SHORT_URL_TAGS_EDITED,
});

View file

@ -1,50 +1,62 @@
import ShlinkApiClient from '../../api/ShlinkApiClient';
import { curry } from 'ramda'; import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { shortUrlType } from './shortUrlsList';
/* eslint-disable padding-line-between-statements, newline-after-var */
const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR'; const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlVisitsType = PropTypes.shape({
shortUrl: shortUrlType,
visits: PropTypes.array,
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = { const initialState = {
shortUrl: {}, shortUrl: {},
visits: [], visits: [],
loading: false, loading: false,
error: false error: false,
}; };
export default function dispatch (state = initialState, action) { export default function dispatch(state = initialState, action) {
switch (action.type) { switch (action.type) {
case GET_SHORT_URL_VISITS_START: case GET_SHORT_URL_VISITS_START:
return { return {
...state, ...state,
loading: true loading: true,
}; };
case GET_SHORT_URL_VISITS_ERROR: case GET_SHORT_URL_VISITS_ERROR:
return { return {
...state, ...state,
loading: false, loading: false,
error: true error: true,
}; };
case GET_SHORT_URL_VISITS: case GET_SHORT_URL_VISITS:
return { return {
shortUrl: action.shortUrl, shortUrl: action.shortUrl,
visits: action.visits, visits: action.visits,
loading: false, loading: false,
error: false error: false,
}; };
default: default:
return state; return state;
} }
} }
export const _getShortUrlVisits = (ShlinkApiClient, shortCode, dates) => dispatch => { export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => (dispatch) => {
dispatch({ type: GET_SHORT_URL_VISITS_START }); dispatch({ type: GET_SHORT_URL_VISITS_START });
Promise.all([ Promise.all([
ShlinkApiClient.getShortUrlVisits(shortCode, dates), shlinkApiClient.getShortUrlVisits(shortCode, dates),
ShlinkApiClient.getShortUrl(shortCode) shlinkApiClient.getShortUrl(shortCode),
]) ])
.then(([visits, shortUrl]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS })) .then(([ visits, shortUrl ]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS }))
.catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR })); .catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR }));
}; };
export const getShortUrlVisits = curry(_getShortUrlVisits)(ShlinkApiClient);
export const getShortUrlVisits = curry(_getShortUrlVisits)(shlinkApiClient);

View file

@ -1,8 +1,19 @@
import ShlinkApiClient from '../../api/ShlinkApiClient'; import { assoc, assocPath } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
/* eslint-disable padding-line-between-statements, newline-after-var */
const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR'; const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS'; 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,
});
const initialState = { const initialState = {
shortUrls: {}, shortUrls: {},
@ -17,27 +28,36 @@ export default function reducer(state = initialState, action) {
return { return {
loading: false, loading: false,
error: false, error: false,
shortUrls: action.shortUrls shortUrls: action.shortUrls,
}; };
case LIST_SHORT_URLS_ERROR: case LIST_SHORT_URLS_ERROR:
return { return {
loading: false, loading: false,
error: true, error: true,
shortUrls: [] shortUrls: [],
}; };
case SHORT_URL_TAGS_EDITED:
const { data } = state.shortUrls;
return assocPath([ 'shortUrls', 'data' ], data.map((shortUrl) =>
shortUrl.shortCode === action.shortCode
? assoc('tags', action.tags, shortUrl)
: shortUrl), state);
default: default:
return state; return state;
} }
} }
export const _listShortUrls = (ShlinkApiClient, params = {}) => async dispatch => { export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch) => {
dispatch({ type: LIST_SHORT_URLS_START }); dispatch({ type: LIST_SHORT_URLS_START });
try { try {
const shortUrls = await ShlinkApiClient.listShortUrls(params); const shortUrls = await shlinkApiClient.listShortUrls(params);
dispatch({ type: LIST_SHORT_URLS, shortUrls, params }); dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
} catch (e) { } catch (e) {
dispatch({ type: LIST_SHORT_URLS_ERROR, params }); dispatch({ type: LIST_SHORT_URLS_ERROR, params });
} }
}; };
export const listShortUrls = (params = {}) => _listShortUrls(ShlinkApiClient, params);
export const listShortUrls = (params = {}) => _listShortUrls(shlinkApiClient, params);

View file

@ -1,7 +1,14 @@
import PropTypes from 'prop-types';
import { LIST_SHORT_URLS } from './shortUrlsList'; import { LIST_SHORT_URLS } from './shortUrlsList';
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
export const shortUrlsListParamsType = PropTypes.shape({
page: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
searchTerm: PropTypes.string,
});
const defaultState = { page: '1' }; const defaultState = { page: '1' };
export default function reducer(state = defaultState, action) { export default function reducer(state = defaultState, action) {

74
src/tags/TagCard.js Normal file
View file

@ -0,0 +1,74 @@
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 PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
import './TagCard.scss';
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
import EditTagModal from './helpers/EditTagModal';
const propTypes = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
colorGenerator: colorGeneratorType,
};
const defaultProps = {
colorGenerator,
};
export default class TagCard extends React.Component {
state = { isDeleteModalOpen: false, isEditModalOpen: false };
render() {
const { tag, colorGenerator, currentServerId } = this.props;
const toggleDelete = () =>
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
const toggleEdit = () =>
this.setState(({ isEditModalOpen }) => ({ isEditModalOpen: !isEditModalOpen }));
return (
<Card className="tag-card">
<CardBody className="tag-card__body">
<button
className="btn btn-light btn-sm tag-card__btn tag-card__btn--last"
onClick={toggleDelete}
>
<FontAwesomeIcon icon={deleteIcon} />
</button>
<button
className="btn btn-light btn-sm tag-card__btn"
onClick={toggleEdit}
>
<FontAwesomeIcon icon={editIcon} />
</button>
<h5 className="tag-card__tag-title">
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-card__tag-bullet"
/>
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>
{tag}
</Link>
</h5>
</CardBody>
<DeleteTagConfirmModal
tag={tag}
toggle={toggleDelete}
isOpen={this.state.isDeleteModalOpen}
/>
<EditTagModal
tag={tag}
toggle={toggleEdit}
isOpen={this.state.isEditModalOpen}
/>
</Card>
);
}
}
TagCard.propTypes = propTypes;
TagCard.defaultProps = defaultProps;

36
src/tags/TagCard.scss Normal file
View file

@ -0,0 +1,36 @@
.tag-card.tag-card {
background-color: #eee;
margin-bottom: .5rem;
}
.tag-card__body.tag-card__body {
padding: .75rem;
}
.tag-card__tag-title {
margin: 0;
line-height: 31px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-right: 5px;
}
.tag-card__tag-bullet {
$width: 20px;
border-radius: 50%;
width: $width;
height: $width;
display: inline-block;
vertical-align: -4px;
margin-right: 7px;
}
.tag-card__btn {
float: right;
}
.tag-card__btn--last {
margin-left: 3px;
}

92
src/tags/TagsList.js Normal file
View file

@ -0,0 +1,92 @@
import React from 'react';
import { connect } from 'react-redux';
import { pick, splitEvery } from 'ramda';
import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage';
import SearchField from '../utils/SearchField';
import { filterTags, listTags } from './reducers/tagsList';
import TagCard from './TagCard';
const { ceil } = Math;
const TAGS_GROUP_SIZE = 4;
const propTypes = {
filterTags: PropTypes.func,
listTags: PropTypes.func,
tagsList: PropTypes.shape({
loading: PropTypes.bool,
}),
match: PropTypes.object,
};
export class TagsListComponent extends React.Component {
componentDidMount() {
const { listTags } = this.props;
listTags();
}
renderContent() {
const { tagsList, match } = this.props;
if (tagsList.loading) {
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>;
}
if (tagsList.error) {
return (
<div className="col-12">
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
</div>
);
}
const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) {
return <MuttedMessage>No tags found</MuttedMessage>;
}
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUP_SIZE), tagsList.filteredTags);
return (
<React.Fragment>
{tagsGroups.map((group, index) => (
<div key={index} className="col-md-6 col-xl-3">
{group.map((tag) => (
<TagCard
key={tag}
tag={tag}
currentServerId={match.params.serverId}
/>
))}
</div>
))}
</React.Fragment>
);
}
render() {
const { filterTags } = this.props;
return (
<div className="shlink-container">
{!this.props.tagsList.loading && (
<SearchField
className="mb-3"
placeholder="Search tags..."
onChange={filterTags}
/>
)}
<div className="row">
{this.renderContent()}
</div>
</div>
);
}
}
TagsListComponent.propTypes = propTypes;
const TagsList = connect(pick([ 'tagsList' ]), { listTags, filterTags })(TagsListComponent);
export default TagsList;

View file

@ -0,0 +1,78 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete';
const propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
deleteTag: PropTypes.func,
tagDelete: tagDeleteType,
tagDeleted: PropTypes.func,
};
export class DeleteTagConfirmModalComponent extends React.Component {
doDelete = () => {
const { tag, toggle, deleteTag } = this.props;
deleteTag(tag).then(() => {
this.tagWasDeleted = true;
toggle();
});
};
handleOnClosed = () => {
if (!this.tagWasDeleted) {
return;
}
const { tagDeleted, tag } = this.props;
tagDeleted(tag);
};
componentDidMount() {
this.tagWasDeleted = false;
}
render() {
const { tag, toggle, isOpen, tagDelete } = this.props;
return (
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={this.handleOnClosed}>
<ModalHeader toggle={toggle}>
<span className="text-danger">Delete tag</span>
</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{tagDelete.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the tag :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button
className="btn btn-danger"
disabled={tagDelete.deleting}
onClick={() => this.doDelete()}
>
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
</button>
</ModalFooter>
</Modal>
);
}
}
DeleteTagConfirmModalComponent.propTypes = propTypes;
const DeleteTagConfirmModal = connect(
pick([ 'tagDelete' ]),
{ deleteTag, tagDeleted }
)(DeleteTagConfirmModalComponent);
export default DeleteTagConfirmModal;

View file

@ -0,0 +1,141 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { pick } from 'ramda';
import { ChromePicker } from 'react-color';
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import { editTag, tagEdited } from '../reducers/tagEdit';
import './EditTagModal.scss';
const propTypes = {
tag: PropTypes.string,
editTag: PropTypes.func,
toggle: PropTypes.func,
tagEdited: PropTypes.func,
colorGenerator: colorGeneratorType,
isOpen: PropTypes.bool,
tagEdit: PropTypes.shape({
error: PropTypes.bool,
editing: PropTypes.bool,
}),
};
const defaultProps = {
colorGenerator,
};
export class EditTagModalComponent extends React.Component {
saveTag = (e) => {
e.preventDefault();
const { tag: oldName, editTag, toggle } = this.props;
const { tag: newName, color } = this.state;
editTag(oldName, newName, color)
.then(() => {
this.tagWasEdited = true;
toggle();
})
.catch(() => {});
};
handleOnClosed = () => {
if (!this.tagWasEdited) {
return;
}
const { tag: oldName, tagEdited } = this.props;
const { tag: newName, color } = this.state;
tagEdited(oldName, newName, color);
};
constructor(props) {
super(props);
const { colorGenerator, tag } = props;
this.state = {
showColorPicker: false,
tag,
color: colorGenerator.getColorForKey(tag),
};
}
componentDidMount() {
this.tagWasEdited = false;
}
render() {
const { isOpen, toggle, tagEdit } = this.props;
const { tag, color } = this.state;
const toggleColorPicker = () =>
this.setState(({ showColorPicker }) => ({ showColorPicker: !showColorPicker }));
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.handleOnClosed}>
<form onSubmit={(e) => this.saveTag(e)}>
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
<ModalBody>
<div className="input-group">
<div
className="input-group-prepend"
id="colorPickerBtn"
onClick={toggleColorPicker}
>
<div
className="input-group-text edit-tag-modal__color-picker-toggle"
style={{
backgroundColor: color,
borderColor: color,
}}
>
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
</div>
</div>
<Popover
isOpen={this.state.showColorPicker}
toggle={toggleColorPicker}
target="colorPickerBtn"
placement="right"
>
<ChromePicker
color={color}
disableAlpha
onChange={(color) => this.setState({ color: color.hex })}
/>
</Popover>
<input
type="text"
value={tag}
placeholder="Tag"
required
className="form-control"
onChange={(e) => this.setState({ tag: e.target.value })}
/>
</div>
{tagEdit.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while editing the tag :(
</div>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={tagEdit.editing}>
{tagEdit.editing ? 'Saving...' : 'Save'}
</button>
</ModalFooter>
</form>
</Modal>
);
}
}
EditTagModalComponent.propTypes = propTypes;
EditTagModalComponent.defaultProps = defaultProps;
const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent);
export default EditTagModal;

View file

@ -0,0 +1,7 @@
.edit-tag-modal__color-picker-toggle {
cursor: pointer;
}
.edit-tag-modal__color-icon {
color: #fff;
}

View file

@ -0,0 +1,59 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const tagDeleteType = PropTypes.shape({
deleting: PropTypes.bool,
error: PropTypes.bool,
});
const defaultState = {
deleting: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case DELETE_TAG_START:
return {
deleting: true,
error: false,
};
case DELETE_TAG_ERROR:
return {
deleting: false,
error: true,
};
case DELETE_TAG:
return {
deleting: false,
error: false,
};
default:
return state;
}
}
export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => {
dispatch({ type: DELETE_TAG_START });
try {
await shlinkApiClient.deleteTags([ tag ]);
dispatch({ type: DELETE_TAG });
} catch (e) {
dispatch({ type: DELETE_TAG_ERROR });
throw e;
}
};
export const deleteTag = curry(_deleteTag)(shlinkApiClient);
export const tagDeleted = (tag) => ({ type: TAG_DELETED, tag });

View file

@ -0,0 +1,67 @@
import { curry, pick } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import colorGenerator from '../../utils/ColorGenerator';
/* eslint-disable padding-line-between-statements, newline-after-var */
const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
const defaultState = {
oldName: '',
newName: '',
editing: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case EDIT_TAG_START:
return {
...state,
editing: true,
error: false,
};
case EDIT_TAG_ERROR:
return {
...state,
editing: false,
error: true,
};
case EDIT_TAG:
return {
...pick([ 'oldName', 'newName' ], action),
editing: false,
error: false,
};
default:
return state;
}
}
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) =>
async (dispatch) => {
dispatch({ type: EDIT_TAG_START });
try {
await shlinkApiClient.editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) {
dispatch({ type: EDIT_TAG_ERROR });
throw e;
}
};
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);
export const tagEdited = (oldName, newName, color) => ({
type: TAG_EDITED,
oldName,
newName,
color,
});

View file

@ -0,0 +1,88 @@
import { reject } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
/* eslint-disable padding-line-between-statements, newline-after-var */
const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements, newline-after-var */
const defaultState = {
tags: [],
filteredTags: [],
loading: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case LIST_TAGS_START:
return {
...state,
loading: true,
error: false,
};
case LIST_TAGS_ERROR:
return {
...state,
loading: false,
error: true,
};
case LIST_TAGS:
return {
tags: action.tags,
filteredTags: action.tags,
loading: false,
error: false,
};
case TAG_DELETED:
return {
...state,
// FIXME This should be optimized somehow...
tags: reject((tag) => tag === action.tag, state.tags),
filteredTags: reject((tag) => tag === action.tag, state.filteredTags),
};
case TAG_EDITED:
const renameTag = (tag) => tag === action.oldName ? action.newName : tag;
return {
...state,
// FIXME This should be optimized somehow...
tags: state.tags.map(renameTag).sort(),
filteredTags: state.filteredTags.map(renameTag).sort(),
};
case FILTER_TAGS:
return {
...state,
filteredTags: state.tags.filter(
(tag) => tag.toLowerCase().match(action.searchTerm),
),
};
default:
return state;
}
}
export const _listTags = (shlinkApiClient) => async (dispatch) => {
dispatch({ type: LIST_TAGS_START });
try {
const tags = await shlinkApiClient.listTags();
dispatch({ tags, type: LIST_TAGS });
} catch (e) {
dispatch({ type: LIST_TAGS_ERROR });
}
};
export const listTags = () => _listTags(shlinkApiClient);
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,
searchTerm,
});

View file

@ -1,13 +1,16 @@
import Storage from './Storage'; import { range } from 'ramda';
import PropTypes from 'prop-types';
import storage from './Storage';
const buildRandomColor = () => { const HEX_COLOR_LENGTH = 6;
const letters = '0123456789ABCDEF'; const { floor, random } = Math;
let color = '#'; const letters = '0123456789ABCDEF';
for (let i = 0; i < 6; i++ ) { const buildRandomColor = () =>
color += letters[Math.floor(Math.random() * 16)]; `#${
} range(0, HEX_COLOR_LENGTH)
return color; .map(() => letters[floor(random() * letters.length)])
}; .join('')
}`;
export class ColorGenerator { export class ColorGenerator {
constructor(storage) { constructor(storage) {
@ -15,18 +18,30 @@ export class ColorGenerator {
this.colors = this.storage.get('colors') || {}; this.colors = this.storage.get('colors') || {};
} }
getColorForKey = key => { getColorForKey = (key) => {
let color = this.colors[key]; const color = this.colors[key];
if (color) {
return color;
}
// If a color has not been set yet, generate a random one and save it // If a color has not been set yet, generate a random one and save it
color = buildRandomColor(); if (!color) {
this.colors[key] = color; this.setColorForKey(key, buildRandomColor());
this.storage.set('colors', this.colors);
return this.getColorForKey(key);
}
return color; return color;
}; };
setColorForKey = (key, color) => {
this.colors[key] = color;
this.storage.set('colors', this.colors);
}
} }
export default new ColorGenerator(Storage); export const colorGeneratorType = PropTypes.shape({
getColorForKey: PropTypes.func,
setColorForKey: PropTypes.func,
});
const colorGenerator = new ColorGenerator(storage);
export default colorGenerator;

19
src/utils/ExternalLink.js Normal file
View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
href: PropTypes.string.isRequired,
children: PropTypes.node,
};
export default function ExternalLink(props) {
const { href, children, ...rest } = props;
return (
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
{children}
</a>
);
}
ExternalLink.propTypes = propTypes;

View file

@ -0,0 +1,28 @@
import React from 'react';
import { Card } from 'reactstrap';
import classnames from 'classnames';
import PropTypes from 'prop-types';
const DEFAULT_MARGIN_SIZE = 4;
const propTypes = {
marginSize: PropTypes.number,
children: PropTypes.node,
};
export default function MutedMessage({ children, marginSize = DEFAULT_MARGIN_SIZE }) {
const cardClasses = classnames('bg-light', {
[`mt-${marginSize}`]: marginSize > 0,
});
return (
<div className="col-md-10 offset-md-1">
<Card className={cardClasses} body>
<h3 className="text-center text-muted mb-0">
{children}
</h3>
</Card>
</div>
);
}
MutedMessage.propTypes = propTypes;

69
src/utils/SearchField.js Normal file
View file

@ -0,0 +1,69 @@
import React from 'react';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import searchIcon from '@fortawesome/fontawesome-free-solid/faSearch';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500;
const propTypes = {
onChange: PropTypes.func.isRequired,
className: PropTypes.string,
placeholder: PropTypes.string,
};
const defaultProps = {
className: '',
placeholder: 'Search...',
};
export default class SearchField extends React.Component {
state = { showClearBtn: false, searchTerm: '' };
timer = null;
searchTermChanged(searchTerm, timeout = DEFAULT_SEARCH_INTERVAL) {
this.setState({
showClearBtn: searchTerm !== '',
searchTerm,
});
const resetTimer = () => {
clearTimeout(this.timer);
this.timer = null;
};
resetTimer();
this.timer = setTimeout(() => {
this.props.onChange(searchTerm);
resetTimer();
}, timeout);
}
render() {
const { className, placeholder } = this.props;
return (
<div className={classnames('search-field', className)}>
<input
type="text"
className="form-control form-control-lg search-field__input"
placeholder={placeholder}
value={this.state.searchTerm}
onChange={(e) => this.searchTermChanged(e.target.value)}
/>
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
<div
className="close search-field__close"
hidden={!this.state.showClearBtn}
id="search-field__close"
onClick={() => this.searchTermChanged('', 0)}
>
&times;
</div>
</div>
);
}
}
SearchField.propTypes = propTypes;
SearchField.defaultProps = defaultProps;

View file

@ -0,0 +1,23 @@
@import '../utils/mixins/vertical-align';
.search-field {
position: relative;
}
.search-field__input.search-field__input {
padding-left: 40px;
padding-right: 40px;
}
.search-field__icon {
@include vertical-align();
left: 15px;
color: #707581;
}
.search-field__close {
@include vertical-align();
right: 15px;
}

View file

@ -1,18 +1,28 @@
const PREFIX = 'shlink'; const PREFIX = 'shlink';
const buildPath = path => `${PREFIX}.${path}`; const buildPath = (path) => `${PREFIX}.${path}`;
export class Storage { export class Storage {
constructor(localStorage) { constructor(localStorage) {
this.localStorage = localStorage; this.localStorage = localStorage;
} }
get = key => { get = (key) => {
const item = this.localStorage.getItem(buildPath(key)); const item = this.localStorage.getItem(buildPath(key));
return item ? JSON.parse(item) : undefined; return item ? JSON.parse(item) : undefined;
}; };
set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value)); set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
} }
const storage = typeof localStorage !== 'undefined' ? localStorage : {}; const browserStorage = global.localStorage || {
export default new Storage(storage); getItem() {
return '';
},
setItem() {
return '';
},
};
const storage = new Storage(browserStorage);
export default storage;

View file

@ -1,14 +1,28 @@
import React from 'react'; import React from 'react';
import ColorGenerator from '../utils/ColorGenerator'; import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
import './Tag.scss'; import './Tag.scss';
export default function Tag ( const propTypes = {
colorGenerator: colorGeneratorType,
text: PropTypes.string,
children: PropTypes.node,
clearable: PropTypes.bool,
onClick: PropTypes.func,
onClose: PropTypes.func,
};
const defaultProps = {
colorGenerator,
};
export default function Tag(
{ {
colorGenerator, colorGenerator,
text, text,
children,
clearable, clearable,
onClick = () => ({}), onClick = () => ({}),
onClose = () => ({}) onClose = () => ({}),
} }
) { ) {
return ( return (
@ -17,12 +31,11 @@ export default function Tag (
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }} style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
onClick={onClick} onClick={onClick}
> >
{text} {children || text}
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>} {clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>}
</span> </span>
); );
} }
Tag.defaultProps = { Tag.defaultProps = defaultProps;
colorGenerator: ColorGenerator Tag.propTypes = propTypes;
};

44
src/utils/TagsSelector.js Normal file
View file

@ -0,0 +1,44 @@
import React from 'react';
import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from './ColorGenerator';
const defaultProps = {
colorGenerator,
placeholder: 'Add tags to the URL',
};
const propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
colorGenerator: colorGeneratorType,
};
export default function TagsSelector({ tags, onChange, placeholder, colorGenerator }) {
const renderTag = (props) => {
const { tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other } = props;
return (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)}
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
</span>
);
};
return (
<TagsInput
value={tags}
inputProps={{ placeholder }}
onlyUnique
renderTag={renderTag}
// FIXME Workaround to be able to add tags on Android
addOnBlur
onChange={onChange}
/>
);
}
TagsSelector.defaultProps = defaultProps;
TagsSelector.propTypes = propTypes;

View file

@ -1,101 +1,91 @@
import { forEach, isNil, isEmpty } from 'ramda'; import { assoc, isNil, isEmpty, reduce } from 'ramda';
const osFromUserAgent = userAgent => { const osFromUserAgent = (userAgent) => {
const lowerUserAgent = userAgent.toLowerCase(); const lowerUserAgent = userAgent.toLowerCase();
switch (true) { switch (true) {
case (lowerUserAgent.indexOf('linux') >= 0): case lowerUserAgent.indexOf('linux') >= 0:
return 'Linux'; return 'Linux';
case (lowerUserAgent.indexOf('windows') >= 0): case lowerUserAgent.indexOf('windows') >= 0:
return 'Windows'; return 'Windows';
case (lowerUserAgent.indexOf('mac') >= 0): case lowerUserAgent.indexOf('mac') >= 0:
return 'MacOS'; return 'MacOS';
case (lowerUserAgent.indexOf('mobi') >= 0): case lowerUserAgent.indexOf('mobi') >= 0:
return 'Mobile'; return 'Mobile';
default: default:
return 'Others'; return 'Others';
} }
}; };
const browserFromUserAgent = userAgent => { const browserFromUserAgent = (userAgent) => {
const lowerUserAgent = userAgent.toLowerCase(); const lowerUserAgent = userAgent.toLowerCase();
switch (true) { switch (true) {
case (lowerUserAgent.indexOf('firefox') >= 0): case lowerUserAgent.indexOf('opera') >= 0 || lowerUserAgent.indexOf('opr') >= 0:
return 'Firefox';
case (lowerUserAgent.indexOf('chrome') >= 0):
return 'Chrome';
case (lowerUserAgent.indexOf('safari') >= 0):
return 'Safari';
case (lowerUserAgent.indexOf('opera') >= 0):
return 'Opera'; return 'Opera';
case (lowerUserAgent.indexOf('msie') >= 0): case lowerUserAgent.indexOf('firefox') >= 0:
return 'Firefox';
case lowerUserAgent.indexOf('chrome') >= 0:
return 'Chrome';
case lowerUserAgent.indexOf('safari') >= 0:
return 'Safari';
case lowerUserAgent.indexOf('msie') >= 0:
return 'Internet Explorer'; return 'Internet Explorer';
default: default:
return 'Others'; return 'Others';
} }
}; };
const extractDomain = url => { const extractDomain = (url) => {
const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0]; const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
return domain.split(':')[0]; return domain.split(':')[0];
}; };
// FIXME Refactor these foreach statements which mutate a stats object export const processOsStats = (visits) =>
export class VisitsParser { reduce(
processOsStats = visits => { (stats, { userAgent }) => {
const stats = {};
forEach(visit => {
const userAgent = visit.userAgent;
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent); const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
stats[os] = typeof stats[os] === 'undefined' ? 1 : stats[os] + 1; return assoc(os, (stats[os] || 0) + 1, stats);
}, visits); },
{},
visits,
);
return stats; export const processBrowserStats = (visits) =>
}; reduce(
(stats, { userAgent }) => {
processBrowserStats = visits => {
const stats = {};
forEach(visit => {
const userAgent = visit.userAgent;
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent); const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
stats[browser] = typeof stats[browser] === 'undefined' ? 1 : stats[browser] + 1; return assoc(browser, (stats[browser] || 0) + 1, stats);
}, visits); },
{},
visits,
);
return stats; export const processReferrersStats = (visits) =>
}; reduce(
(stats, visit) => {
processReferrersStats = visits => {
const stats = {};
forEach(visit => {
const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer); const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer); const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer);
stats[domain] = typeof stats[domain] === 'undefined' ? 1 : stats[domain] + 1; return assoc(domain, (stats[domain] || 0) + 1, stats);
}, visits); },
{},
visits,
);
return stats; export const processCountriesStats = (visits) =>
}; reduce(
(stats, { visitLocation }) => {
processCountriesStats = visits => {
const stats = {};
forEach(({ visitLocation }) => {
const notHasCountry = isNil(visitLocation) const notHasCountry = isNil(visitLocation)
|| isNil(visitLocation.countryName) || isNil(visitLocation.countryName)
|| isEmpty(visitLocation.countryName); || isEmpty(visitLocation.countryName);
const country = notHasCountry ? 'Unknown' : visitLocation.countryName; const country = notHasCountry ? 'Unknown' : visitLocation.countryName;
stats[country] = typeof stats[country] === 'undefined' ? 1 : stats[country] + 1; return assoc(country, (stats[country] || 0) + 1, stats);
}, visits); },
{},
return stats; visits,
}; );
}
export default new VisitsParser();

27
test/App.test.js Normal file
View file

@ -0,0 +1,27 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Route } from 'react-router-dom';
import App from '../src/App';
import MainHeader from '../src/common/MainHeader';
describe('<App />', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<App />);
});
afterEach(() => wrapper.unmount());
it('renders a header', () => expect(wrapper.find(MainHeader)).toHaveLength(1));
it('renders app main routes', () => {
const routes = wrapper.find(Route);
const expectedRoutesCount = 3;
const second = 2;
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');
});
});

View file

@ -1,26 +1,167 @@
import { ShlinkApiClient } from '../../src/api/ShlinkApiClient' import sinon from 'sinon';
import { head, last } from 'ramda';
import { ShlinkApiClient } from '../../src/api/ShlinkApiClient';
describe('ShlinkApiClient', () => { describe('ShlinkApiClient', () => {
const createApiClient = extraData => { const createAxiosMock = (extraData) => () =>
const axiosMock = () => Promise.resolve({
Promise.resolve({ headers: { authorization: 'Bearer abc123' },
data: { token: 'foo', ...extraData }, data: { token: 'abc123' },
headers: { authorization: 'Bearer abc123' } ...extraData,
}); });
const createApiClient = (extraData) =>
return new ShlinkApiClient(axiosMock); new ShlinkApiClient(createAxiosMock(extraData));
};
describe('listShortUrls', () => { describe('listShortUrls', () => {
it('properly returns short URLs list', async () => { it('properly returns short URLs list', async () => {
const expectedList = ['foo', 'bar']; const expectedList = [ 'foo', 'bar' ];
const apiClient = createApiClient({ const apiClient = createApiClient({
shortUrls: expectedList data: {
shortUrls: expectedList,
},
}); });
const actualList = await apiClient.listShortUrls(); const actualList = await apiClient.listShortUrls();
expect(expectedList).toEqual(actualList); expect(expectedList).toEqual(actualList);
}); });
}); });
describe('createShortUrl', () => {
const shortUrl = {
bar: 'foo',
};
it('returns create short URL', async () => {
const apiClient = createApiClient({ data: shortUrl });
const result = await apiClient.createShortUrl({});
expect(result).toEqual(shortUrl);
});
it('removes all empty options', async () => {
const axiosSpy = sinon.spy(createAxiosMock({ data: shortUrl }));
const apiClient = new ShlinkApiClient(axiosSpy);
await apiClient.createShortUrl(
{ foo: 'bar', empty: undefined, anotherEmpty: null }
);
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(axiosArgs.data).toEqual({ foo: 'bar' });
});
});
describe('getShortUrlVisits', () => {
it('properly returns short URL visits', async () => {
const expectedVisits = [ 'foo', 'bar' ];
const axiosSpy = sinon.spy(createAxiosMock({
data: {
visits: {
data: expectedVisits,
},
},
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const actualVisits = await apiClient.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(axiosArgs.method).toEqual('GET');
});
});
describe('getShortUrl', () => {
it('properly returns short URL', async () => {
const expectedShortUrl = { foo: 'bar' };
const axiosSpy = sinon.spy(createAxiosMock({
data: expectedShortUrl,
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const result = await apiClient.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.method).toEqual('GET');
});
});
describe('updateShortUrlTags', () => {
it('properly updates short URL tags', async () => {
const expectedTags = [ 'foo', 'bar' ];
const axiosSpy = sinon.spy(createAxiosMock({
data: { tags: expectedTags },
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const result = await apiClient.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.method).toEqual('PUT');
});
});
describe('listTags', () => {
it('properly returns list of tags', async () => {
const expectedTags = [ 'foo', 'bar' ];
const axiosSpy = sinon.spy(createAxiosMock({
data: {
tags: { data: expectedTags },
},
}));
const apiClient = new ShlinkApiClient(axiosSpy);
const result = await apiClient.listTags();
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(expectedTags).toEqual(result);
expect(axiosArgs.url).toContain('/tags');
expect(axiosArgs.method).toEqual('GET');
});
});
describe('deleteTags', () => {
it('properly deletes provided tags', async () => {
const tags = [ 'foo', 'bar' ];
const axiosSpy = sinon.spy(createAxiosMock({}));
const apiClient = new ShlinkApiClient(axiosSpy);
await apiClient.deleteTags(tags);
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(axiosArgs.url).toContain('/tags');
expect(axiosArgs.method).toEqual('DELETE');
expect(axiosArgs.params).toEqual({ tags });
});
});
describe('editTag', () => {
it('properly deletes provided tags', async () => {
const oldName = 'foo';
const newName = 'bar';
const axiosSpy = sinon.spy(createAxiosMock({}));
const apiClient = new ShlinkApiClient(axiosSpy);
await apiClient.editTag(oldName, newName);
const lastAxiosCall = last(axiosSpy.getCalls());
const axiosArgs = head(lastAxiosCall.args);
expect(axiosArgs.url).toContain('/tags');
expect(axiosArgs.method).toEqual('PUT');
expect(axiosArgs.data).toEqual({ oldName, newName });
});
});
}); });

View file

@ -1,6 +1,7 @@
import { shallow } from 'enzyme' import { shallow } from 'enzyme';
import React from 'react' import React from 'react';
import AsideMenu from '../../src/common/AsideMenu' import { NavLink } from 'react-router-dom';
import AsideMenu from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => { describe('<AsideMenu />', () => {
let wrapped; let wrapped;
@ -12,11 +13,12 @@ describe('<AsideMenu />', () => {
wrapped.unmount(); wrapped.unmount();
}); });
it('contains links to selected server', () => { it('contains links to different sections', () => {
const links = wrapped.find('NavLink'); const links = wrapped.find(NavLink);
const expectedLength = 3;
expect(links).toHaveLength(2); expect(links).toHaveLength(expectedLength);
links.forEach(link => expect(link.prop('to')).toContain('abc123')); links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
}); });
it('contains a button to delete server', () => { it('contains a button to delete server', () => {

View file

@ -1,16 +1,18 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import DateInput from '../../src/common/DateInput';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import moment from 'moment'; import moment from 'moment';
import DateInput from '../../src/common/DateInput';
describe('<DateInput />', () => { describe('<DateInput />', () => {
let wrapped; let wrapped;
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapped = shallow(<DateInput {...props} />); wrapped = shallow(<DateInput {...props} />);
return wrapped; return wrapped;
}; };
afterEach(() => { afterEach(() => {
if (wrapped !== undefined) { if (wrapped !== undefined) {
wrapped.unmount(); wrapped.unmount();

View file

@ -2,17 +2,21 @@ import { shallow } from 'enzyme';
import { values } from 'ramda'; import { values } from 'ramda';
import React from 'react'; import React from 'react';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { Home } from '../../src/common/Home'; import { HomeComponent } from '../../src/common/Home';
describe('<Home />', () => { describe('<Home />', () => {
let wrapped; let wrapped;
const defaultProps = { const defaultProps = {
resetSelectedServer: () => {}, resetSelectedServer() {
return '';
},
servers: {}, servers: {},
}; };
const createComponent = props => { const createComponent = (props) => {
const actualProps = { ...defaultProps, ...props }; const actualProps = { ...defaultProps, ...props };
wrapped = shallow(<Home {...actualProps} />);
wrapped = shallow(<HomeComponent {...actualProps} />);
return wrapped; return wrapped;
}; };
@ -42,7 +46,7 @@ describe('<Home />', () => {
const servers = { const servers = {
1: { name: 'foo', id: '123' }, 1: { name: 'foo', id: '123' },
2: { name: 'bar', id: '456' }, 2: { name: 'bar', id: '456' },
} };
const wrapped = createComponent({ servers }); const wrapped = createComponent({ servers });
expect(wrapped.find('Link')).toHaveLength(0); expect(wrapped.find('Link')).toHaveLength(0);

View file

@ -0,0 +1,66 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import sinon from 'sinon';
import { CreateServerComponent } from '../../src/servers/CreateServer';
import ImportServersBtn from '../../src/servers/helpers/ImportServersBtn';
describe('<CreateServer />', () => {
let wrapper;
const createServerMock = sinon.fake();
const historyMock = {
push: sinon.fake(),
};
beforeEach(() => {
createServerMock.resetHistory();
historyMock.push.resetHistory();
wrapper = shallow(
<CreateServerComponent
createServer={createServerMock}
resetSelectedServer={identity}
history={historyMock}
/>
);
});
afterEach(() => wrapper.unmount());
it('renders components', () => {
expect(wrapper.find('#name')).toHaveLength(1);
expect(wrapper.find('#url')).toHaveLength(1);
expect(wrapper.find('#apiKey')).toHaveLength(1);
expect(wrapper.find(ImportServersBtn)).toHaveLength(1);
expect(wrapper.find('.create-server__import-success-msg')).toHaveLength(0);
});
it('shows success message when imported is true', () => {
wrapper.setState({ serversImported: true });
expect(wrapper.find('.create-server__import-success-msg')).toHaveLength(1);
});
it('creates server and redirects to it when form is submitted', () => {
const form = wrapper.find('form');
form.simulate('submit', { preventDefault() {
return '';
} });
expect(createServerMock.callCount).toEqual(1);
expect(historyMock.push.callCount).toEqual(1);
});
it('updates state when inputs are changed', () => {
const nameInput = wrapper.find('#name');
const urlInput = wrapper.find('#url');
const apiKeyInput = wrapper.find('#apiKey');
nameInput.simulate('change', { target: { value: 'the_name' } });
urlInput.simulate('change', { target: { value: 'the_url' } });
apiKeyInput.simulate('change', { target: { value: 'the_api_key' } });
expect(wrapper.state('name')).toEqual('the_name');
expect(wrapper.state('url')).toEqual('the_url');
expect(wrapper.state('apiKey')).toEqual('the_api_key');
});
});

View file

@ -0,0 +1,34 @@
import React from 'react';
import { shallow } from 'enzyme';
import DeleteServerButton from '../../src/servers/DeleteServerButton';
import DeleteServerModal from '../../src/servers/DeleteServerModal';
describe('<DeleteServerButton />', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<DeleteServerButton server={{}} className="button" />);
});
afterEach(() => wrapper.unmount());
it('renders a button and a modal', () => {
expect(wrapper.find('.button')).toHaveLength(1);
expect(wrapper.find(DeleteServerModal)).toHaveLength(1);
});
it('displays modal when button is clicked', () => {
const btn = wrapper.find('.button');
expect(wrapper.state('isModalOpen')).toEqual(false);
btn.simulate('click');
expect(wrapper.state('isModalOpen')).toEqual(true);
});
it('changes modal open state when toggled', () => {
const modal = wrapper.find(DeleteServerModal);
expect(wrapper.state('isModalOpen')).toEqual(false);
modal.prop('toggle')();
expect(wrapper.state('isModalOpen')).toEqual(true);
});
});

View file

@ -0,0 +1,65 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { DeleteServerModalComponent } from '../../src/servers/DeleteServerModal';
describe('<DeleteServerModal />', () => {
let wrapper;
const deleteServerMock = sinon.fake();
const historyMock = { push: sinon.fake() };
const toggleMock = sinon.fake();
const serverName = 'the_server_name';
beforeEach(() => {
toggleMock.resetHistory();
deleteServerMock.resetHistory();
historyMock.push.resetHistory();
wrapper = shallow(
<DeleteServerModalComponent
server={{ name: serverName }}
toggle={toggleMock}
isOpen={true}
deleteServer={deleteServerMock}
history={historyMock}
/>
);
});
afterEach(() => wrapper.unmount());
it('renders a modal window', () => {
expect(wrapper.find(Modal)).toHaveLength(1);
expect(wrapper.find(ModalHeader)).toHaveLength(1);
expect(wrapper.find(ModalBody)).toHaveLength(1);
expect(wrapper.find(ModalFooter)).toHaveLength(1);
});
it('displays the name of the server as part of the content', () => {
const modalBody = wrapper.find(ModalBody);
expect(modalBody.find('p').first().text()).toEqual(
`Are you sure you want to delete server ${serverName}?`
);
});
it('toggles when clicking cancel button', () => {
const cancelBtn = wrapper.find('button').first();
cancelBtn.simulate('click');
expect(toggleMock.callCount).toEqual(1);
expect(deleteServerMock.callCount).toEqual(0);
expect(historyMock.push.callCount).toEqual(0);
});
it('deletes server when clicking accept button', () => {
const acceptBtn = wrapper.find('button').last();
acceptBtn.simulate('click');
expect(toggleMock.callCount).toEqual(1);
expect(deleteServerMock.callCount).toEqual(1);
expect(historyMock.push.callCount).toEqual(1);
});
});

View file

@ -1,22 +1,41 @@
import { identity } from 'ramda'; import { identity, values } from 'ramda';
import React from 'react'; import React from 'react';
import { ServersDropdown } from '../../src/servers/ServersDropdown';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { DropdownItem, DropdownToggle } from 'reactstrap';
import { ServersDropdownComponent } from '../../src/servers/ServersDropdown';
describe('<ServersDropdown />', () => { describe('<ServersDropdown />', () => {
let wrapped; let wrapped;
const servers = [{ name: 'foo', id: 1 }, { name: 'bar', id: 2 }, { name: 'baz', id: 3 }]; const servers = {
'1a': { name: 'foo', id: 1 },
'2b': { name: 'bar', id: 2 },
'3c': { name: 'baz', id: 3 },
};
beforeEach(() => { beforeEach(() => {
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} />); wrapped = shallow(<ServersDropdownComponent servers={servers} listServers={identity} />);
}); });
afterEach(() => wrapped.unmount()); afterEach(() => wrapped.unmount());
it('contains the list of servers', () => { it('contains the list of servers', () =>
expect(wrapped.find('DropdownItem').length).toEqual(servers.length); expect(wrapped.find(DropdownItem).filter('[to]')).toHaveLength(values(servers).length));
it('contains a toggle with proper title', () =>
expect(wrapped.find(DropdownToggle)).toHaveLength(1));
it('contains a button to export servers', () => {
const items = wrapped.find(DropdownItem);
expect(items.filter('[divider]')).toHaveLength(1);
expect(items.filter('.servers-dropdown__export-item')).toHaveLength(1);
}); });
it('contains a toggle with proper title', () => { it('contains a message when no servers exist yet', () => {
expect(wrapped.find('DropdownToggle').length).toEqual(1); wrapped = shallow(<ServersDropdownComponent servers={{}} listServers={identity} />);
const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1);
expect(item.prop('disabled')).toEqual(true);
expect(item.find('i').text()).toEqual('Add a server first...');
}); });
}); });

View file

@ -0,0 +1,61 @@
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import { UncontrolledTooltip } from 'reactstrap';
import { ImportServersBtnComponent } from '../../../src/servers/helpers/ImportServersBtn';
describe('<ImportServersBtn />', () => {
let wrapper;
const onImportMock = sinon.fake();
const createServersMock = sinon.fake();
const serversImporterMock = {
importServersFromFile: sinon.fake.returns(Promise.resolve([])),
};
const fileRef = {
current: { click: sinon.fake() },
};
beforeEach(() => {
onImportMock.resetHistory();
createServersMock.resetHistory();
serversImporterMock.importServersFromFile.resetHistory();
fileRef.current.click.resetHistory();
wrapper = shallow(
<ImportServersBtnComponent
createServers={createServersMock}
serversImporter={serversImporterMock}
fileRef={fileRef}
onImport={onImportMock}
/>
);
});
afterEach(() => wrapper.unmount());
it('renders a button, a tooltip and a file input', () => {
expect(wrapper.find('#importBtn')).toHaveLength(1);
expect(wrapper.find(UncontrolledTooltip)).toHaveLength(1);
expect(wrapper.find('.create-server__csv-select')).toHaveLength(1);
});
it('triggers click on file ref when button is clicked', () => {
const btn = wrapper.find('#importBtn');
btn.simulate('click');
expect(fileRef.current.click.callCount).toEqual(1);
});
it('imports servers when file input changes', (done) => {
const file = wrapper.find('.create-server__csv-select');
file.simulate('change', { target: { files: [ '' ] } });
setImmediate(() => {
expect(serversImporterMock.importServersFromFile.callCount).toEqual(1);
expect(createServersMock.callCount).toEqual(1);
expect(onImportMock.callCount).toEqual(1);
done();
});
});
});

View file

@ -1,25 +1,24 @@
import reduce, { import * as sinon from 'sinon';
import reducer, {
_selectServer, _selectServer,
RESET_SELECTED_SERVER, RESET_SELECTED_SERVER,
resetSelectedServer, resetSelectedServer,
SELECT_SERVER, SELECT_SERVER,
} from '../../../src/servers/reducers/selectedServer'; } from '../../../src/servers/reducers/selectedServer';
import * as sinon from 'sinon';
import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams'; import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams';
describe('selectedServerReducer', () => { describe('selectedServerReducer', () => {
describe('reduce', () => { describe('reducer', () => {
it('returns default when action is not handled', () => it('returns default when action is not handled', () =>
expect(reduce(null, { type: 'unknown' })).toEqual(null) expect(reducer(null, { type: 'unknown' })).toEqual(null));
);
it('returns default when action is RESET_SELECTED_SERVER', () => it('returns default when action is RESET_SELECTED_SERVER', () =>
expect(reduce(null, { type: RESET_SELECTED_SERVER })).toEqual(null) expect(reducer(null, { type: RESET_SELECTED_SERVER })).toEqual(null));
);
it('returns selected server when action is SELECT_SERVER', () => { it('returns selected server when action is SELECT_SERVER', () => {
const selectedServer = { id: 'abc123' }; const selectedServer = { id: 'abc123' };
expect(reduce(null, { type: SELECT_SERVER, selectedServer })).toEqual(selectedServer);
expect(reducer(null, { type: SELECT_SERVER, selectedServer })).toEqual(selectedServer);
}); });
}); });
@ -31,14 +30,14 @@ describe('selectedServerReducer', () => {
describe('selectServer', () => { describe('selectServer', () => {
const ShlinkApiClientMock = { const ShlinkApiClientMock = {
setConfig: sinon.spy() setConfig: sinon.spy(),
}; };
const serverId = 'abc123'; const serverId = 'abc123';
const selectedServer = { const selectedServer = {
id: serverId id: serverId,
}; };
const ServersServiceMock = { const ServersServiceMock = {
findServerById: sinon.fake.returns(selectedServer) findServerById: sinon.fake.returns(selectedServer),
}; };
afterEach(() => { afterEach(() => {
@ -48,14 +47,15 @@ describe('selectedServerReducer', () => {
it('dispatches proper actions', () => { it('dispatches proper actions', () => {
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const expectedDispatchCalls = 2;
_selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(dispatch); _selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(dispatch);
expect(dispatch.callCount).toEqual(2); expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.firstCall.calledWith({ type: RESET_SHORT_URL_PARAMS })).toEqual(true); expect(dispatch.firstCall.calledWith({ type: RESET_SHORT_URL_PARAMS })).toEqual(true);
expect(dispatch.secondCall.calledWith({ expect(dispatch.secondCall.calledWith({
type: SELECT_SERVER, type: SELECT_SERVER,
selectedServer selectedServer,
})).toEqual(true); })).toEqual(true);
}); });

View file

@ -1,43 +1,31 @@
import reduce, { import * as sinon from 'sinon';
import { values } from 'ramda';
import reducer, {
_createServer, _createServer,
_deleteServer, _deleteServer,
_listServers, _listServers,
CREATE_SERVER, _createServers,
DELETE_SERVER,
FETCH_SERVERS, FETCH_SERVERS,
} from '../../../src/servers/reducers/server'; } from '../../../src/servers/reducers/server';
import * as sinon from 'sinon';
describe('serverReducer', () => { describe('serverReducer', () => {
const servers = { const servers = {
abc123: { id: 'abc123' }, abc123: { id: 'abc123' },
def456: { id: 'def456' } def456: { id: 'def456' },
}; };
const ServersServiceMock = { const ServersServiceMock = {
listServers: sinon.fake.returns(servers), listServers: sinon.fake.returns(servers),
createServer: sinon.fake(), createServer: sinon.fake(),
deleteServer: sinon.fake(), deleteServer: sinon.fake(),
createServers: sinon.fake(),
}; };
describe('reduce', () => { describe('reducer', () => {
it('returns servers when action is FETCH_SERVERS', () => it('returns servers when action is FETCH_SERVERS', () =>
expect(reduce({}, { type: FETCH_SERVERS, servers })).toEqual(servers) expect(reducer({}, { type: FETCH_SERVERS, servers })).toEqual(servers));
);
it('returns servers when action is DELETE_SERVER', () =>
expect(reduce({}, { type: DELETE_SERVER, servers })).toEqual(servers)
);
it('adds server to list when action is CREATE_SERVER', () => {
const server = { id: 'abc123' };
expect(reduce({}, { type: CREATE_SERVER, server })).toEqual({
[server.id]: server
})
});
it('returns default when action is unknown', () => it('returns default when action is unknown', () =>
expect(reduce({}, { type: 'unknown' })).toEqual({}) expect(reducer({}, { type: 'unknown' })).toEqual({}));
);
}); });
describe('action creators', () => { describe('action creators', () => {
@ -45,6 +33,7 @@ describe('serverReducer', () => {
ServersServiceMock.listServers.resetHistory(); ServersServiceMock.listServers.resetHistory();
ServersServiceMock.createServer.resetHistory(); ServersServiceMock.createServer.resetHistory();
ServersServiceMock.deleteServer.resetHistory(); ServersServiceMock.deleteServer.resetHistory();
ServersServiceMock.createServers.resetHistory();
}); });
describe('listServers', () => { describe('listServers', () => {
@ -55,6 +44,7 @@ describe('serverReducer', () => {
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.callCount).toEqual(1);
expect(ServersServiceMock.createServer.callCount).toEqual(0); expect(ServersServiceMock.createServer.callCount).toEqual(0);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0); expect(ServersServiceMock.deleteServer.callCount).toEqual(0);
expect(ServersServiceMock.createServers.callCount).toEqual(0);
}); });
}); });
@ -68,6 +58,7 @@ describe('serverReducer', () => {
expect(ServersServiceMock.createServer.callCount).toEqual(1); expect(ServersServiceMock.createServer.callCount).toEqual(1);
expect(ServersServiceMock.createServer.firstCall.calledWith(serverToCreate)).toEqual(true); expect(ServersServiceMock.createServer.firstCall.calledWith(serverToCreate)).toEqual(true);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0); expect(ServersServiceMock.deleteServer.callCount).toEqual(0);
expect(ServersServiceMock.createServers.callCount).toEqual(0);
}); });
}); });
@ -79,9 +70,24 @@ describe('serverReducer', () => {
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.callCount).toEqual(1);
expect(ServersServiceMock.createServer.callCount).toEqual(0); expect(ServersServiceMock.createServer.callCount).toEqual(0);
expect(ServersServiceMock.createServers.callCount).toEqual(0);
expect(ServersServiceMock.deleteServer.callCount).toEqual(1); expect(ServersServiceMock.deleteServer.callCount).toEqual(1);
expect(ServersServiceMock.deleteServer.firstCall.calledWith(serverToDelete)).toEqual(true); expect(ServersServiceMock.deleteServer.firstCall.calledWith(serverToDelete)).toEqual(true);
}); });
}); });
describe('createServer', () => {
it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(servers);
const result = _createServers(ServersServiceMock, 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(ServersServiceMock.createServers.firstCall.calledWith(serversToCreate)).toEqual(true);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0);
});
});
}); });
}); });

View file

@ -0,0 +1,97 @@
import sinon from 'sinon';
import { ServersExporter } from '../../../src/servers/services/ServersExporter';
describe('ServersExporter', () => {
const createLinkMock = () => ({
setAttribute: sinon.fake(),
click: sinon.fake(),
style: {},
});
const createWindowMock = (isIe10 = true) => ({
navigator: {
msSaveBlob: isIe10 ? sinon.fake() : undefined,
},
document: {
createElement: sinon.fake.returns(createLinkMock()),
body: {
appendChild: sinon.fake(),
removeChild: sinon.fake(),
},
},
});
const serversServiceMock = {
listServers: sinon.fake.returns({
abc123: {
id: 'abc123',
name: 'foo',
},
def456: {
id: 'def456',
name: 'bar',
},
}),
};
const createCsvjsonMock = (throwError = false) => ({
toCSV: throwError ? sinon.fake.throws('') : sinon.fake.returns(''),
});
describe('exportServers', () => {
let originalConsole;
beforeEach(() => {
originalConsole = global.console;
global.console = { error: sinon.fake() };
global.Blob = class Blob {};
global.URL = { createObjectURL: () => '' };
serversServiceMock.listServers.resetHistory();
});
afterEach(() => {
global.console = originalConsole;
});
it('logs an error if something fails', () => {
const csvjsonMock = createCsvjsonMock(true);
const exporter = new ServersExporter(
serversServiceMock,
createWindowMock(),
csvjsonMock,
);
exporter.exportServers();
expect(global.console.error.callCount).toEqual(1);
expect(csvjsonMock.toCSV.callCount).toEqual(1);
});
it('makes use of msSaveBlob API when available', () => {
const windowMock = createWindowMock();
const exporter = new ServersExporter(
serversServiceMock,
windowMock,
createCsvjsonMock(),
);
exporter.exportServers();
expect(serversServiceMock.listServers.callCount).toEqual(1);
expect(windowMock.navigator.msSaveBlob.callCount).toEqual(1);
expect(windowMock.document.createElement.callCount).toEqual(0);
});
it('makes use of download link API when available', () => {
const windowMock = createWindowMock(false);
const exporter = new ServersExporter(
serversServiceMock,
windowMock,
createCsvjsonMock(),
);
exporter.exportServers();
expect(serversServiceMock.listServers.callCount).toEqual(1);
expect(windowMock.document.createElement.callCount).toEqual(1);
expect(windowMock.document.body.appendChild.callCount).toEqual(1);
expect(windowMock.document.body.removeChild.callCount).toEqual(1);
});
});
});

View file

@ -0,0 +1,47 @@
import sinon from 'sinon';
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
describe('ServersImporter', () => {
const servers = [{ name: 'foo' }, { name: 'bar' }];
const csvjsonMock = {
toObject: sinon.fake.returns(servers),
};
const importer = new ServersImporter(csvjsonMock);
beforeEach(() => csvjsonMock.toObject.resetHistory());
describe('importServersFromFile', () => {
it('rejects with error if no file was provided', async () => {
try {
await importer.importServersFromFile();
} catch (e) {
expect(e).toEqual('No file provided or file is not a CSV');
}
});
it('rejects with error if provided file is not a CSV', async () => {
try {
await importer.importServersFromFile({ type: 'text/html' });
} catch (e) {
expect(e).toEqual('No file provided or file is not a CSV');
}
});
it('reads file when a CSV is provided', async () => {
const readAsText = sinon.fake.returns('');
global.FileReader = class FileReader {
constructor() {
this.readAsText = readAsText;
this.addEventListener = (eventName, listener) =>
listener({ target: { result: '' } });
}
};
await importer.importServersFromFile({ type: 'text/csv' });
expect(readAsText.callCount).toEqual(1);
expect(csvjsonMock.toObject.callCount).toEqual(1);
});
});
});

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