mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
commit
cddca4abcf
104 changed files with 5283 additions and 1515 deletions
40
.eslintrc
Normal file
40
.eslintrc
Normal 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
5
.scrutinizer.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
build:
|
||||||
|
environment:
|
||||||
|
node: v10.4.1
|
||||||
|
tools:
|
||||||
|
external_code_coverage: true
|
11
.stylelintrc
Normal file
11
.stylelintrc
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"stylelint-config-adidas",
|
||||||
|
"stylelint-config-adidas-bem",
|
||||||
|
"stylelint-config-recommended-scss"
|
||||||
|
],
|
||||||
|
"syntax": "scss",
|
||||||
|
"plugins": [
|
||||||
|
"stylelint-scss"
|
||||||
|
]
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
39
CHANGELOG.md
39
CHANGELOG.md
|
@ -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
|
||||||
|
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
@ -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 we’re running in production mode.
|
// Useful for determining whether we’re 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;
|
||||||
}, {}),
|
}, {}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPublicUrl = appPackageJson =>
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,23 +8,26 @@ 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
|
// Necessary for external CSS imports to work
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/2677
|
// https://github.com/facebookincubator/create-react-app/issues/2677
|
||||||
ident: 'postcss',
|
ident: 'postcss',
|
||||||
|
@ -36,7 +38,7 @@ const postCssLoader = {
|
||||||
'>1%',
|
'>1%',
|
||||||
'last 4 versions',
|
'last 4 versions',
|
||||||
'Firefox ESR',
|
'Firefox ESR',
|
||||||
'not ie < 9', // React doesn't support IE8 anyway
|
'not ie < 9',
|
||||||
],
|
],
|
||||||
flexbox: 'no-2009',
|
flexbox: 'no-2009',
|
||||||
}),
|
}),
|
||||||
|
@ -48,15 +50,19 @@ const postCssLoader = {
|
||||||
// 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.
|
// 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.
|
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
|
||||||
devtool: 'cheap-module-source-map',
|
devtool: 'cheap-module-source-map',
|
||||||
|
|
||||||
// These are the "entry points" to our application.
|
// These are the "entry points" to our application.
|
||||||
// This means they will be the "root" imports that are included in JS bundle.
|
// 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.
|
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
|
||||||
entry: [
|
entry: [
|
||||||
|
|
||||||
// We ship a few polyfills by default:
|
// We ship a few polyfills by default:
|
||||||
require.resolve('./polyfills'),
|
require.resolve('./polyfills'),
|
||||||
|
|
||||||
// Include an alternative client for WebpackDevServer. A client's job is to
|
// Include an alternative client for WebpackDevServer. A client's job is to
|
||||||
// connect to WebpackDevServer by a socket and get notified about changes.
|
// 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
|
// When you save a file, the client will either apply hot updates (in case
|
||||||
|
@ -68,36 +74,46 @@ module.exports = {
|
||||||
// require.resolve('webpack-dev-server/client') + '?/',
|
// require.resolve('webpack-dev-server/client') + '?/',
|
||||||
// require.resolve('webpack/hot/dev-server'),
|
// require.resolve('webpack/hot/dev-server'),
|
||||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
require.resolve('react-dev-utils/webpackHotDevClient'),
|
||||||
|
|
||||||
// Finally, this is your app's code:
|
// Finally, this is your app's code:
|
||||||
paths.appIndexJs,
|
paths.appIndexJs,
|
||||||
|
|
||||||
// We include the app code last so that if there is a runtime error during
|
// 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
|
// initialization, it doesn't blow up the WebpackDevServer client, and
|
||||||
// changing JS code would still trigger a refresh.
|
// changing JS code would still trigger a refresh.
|
||||||
],
|
],
|
||||||
output: {
|
output: {
|
||||||
|
|
||||||
// Add /* filename */ comments to generated require()s in the output.
|
// Add /* filename */ comments to generated require()s in the output.
|
||||||
pathinfo: true,
|
pathinfo: true,
|
||||||
|
|
||||||
// This does not produce a real file. It's just the virtual path that is
|
// 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
|
// served by WebpackDevServer in development. This is the JS bundle
|
||||||
// containing code from all our entry points, and the Webpack runtime.
|
// containing code from all our entry points, and the Webpack runtime.
|
||||||
filename: 'static/js/bundle.js',
|
filename: 'static/js/bundle.js',
|
||||||
|
|
||||||
// There are also additional JS chunk files if you use code splitting.
|
// There are also additional JS chunk files if you use code splitting.
|
||||||
chunkFilename: 'static/js/[name].chunk.js',
|
chunkFilename: 'static/js/[name].chunk.js',
|
||||||
|
|
||||||
// This is the URL that app is served from. We use "/" in development.
|
// This is the URL that app is served from. We use "/" in development.
|
||||||
publicPath: publicPath,
|
publicPath,
|
||||||
|
|
||||||
// Point sourcemap entries to original disk location (format as URL on Windows)
|
// Point sourcemap entries to original disk location (format as URL on Windows)
|
||||||
devtoolModuleFilenameTemplate: info =>
|
devtoolModuleFilenameTemplate: (info) =>
|
||||||
path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
|
path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|
||||||
// This allows you to set a fallback for where Webpack should look for modules.
|
// 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"
|
// We placed these paths second because we want `node_modules` to "win"
|
||||||
// if there are any conflicts. This matches Node resolution mechanism.
|
// if there are any conflicts. This matches Node resolution mechanism.
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/253
|
// https://github.com/facebookincubator/create-react-app/issues/253
|
||||||
modules: [ 'node_modules', paths.appNodeModules ].concat(
|
modules: [ 'node_modules', paths.appNodeModules ].concat(
|
||||||
|
|
||||||
// It is guaranteed to exist because we tweak it in `env.js`
|
// It is guaranteed to exist because we tweak it in `env.js`
|
||||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
|
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
|
||||||
),
|
),
|
||||||
|
|
||||||
// These are the reasonable defaults supported by the Node ecosystem.
|
// These are the reasonable defaults supported by the Node ecosystem.
|
||||||
// We also include JSX as a common component filename extension to support
|
// We also include JSX as a common component filename extension to support
|
||||||
// some tools, although we do not recommend using it, see:
|
// some tools, although we do not recommend using it, see:
|
||||||
|
@ -112,6 +128,7 @@ module.exports = {
|
||||||
'react-native': 'react-native-web',
|
'react-native': 'react-native-web',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
||||||
// Prevents users from importing files from outside of src/ (or node_modules/).
|
// 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.
|
// 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,
|
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
|
||||||
|
@ -123,6 +140,7 @@ module.exports = {
|
||||||
module: {
|
module: {
|
||||||
strictExportPresence: true,
|
strictExportPresence: true,
|
||||||
rules: [
|
rules: [
|
||||||
|
|
||||||
// TODO: Disable require.ensure as it's not a standard language feature.
|
// 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.
|
// We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
|
||||||
// { parser: { requireEnsure: false } },
|
// { parser: { requireEnsure: false } },
|
||||||
|
@ -145,10 +163,12 @@ module.exports = {
|
||||||
include: paths.appSrc,
|
include: paths.appSrc,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
||||||
// "oneOf" will traverse all following loaders until one will
|
// "oneOf" will traverse all following loaders until one will
|
||||||
// match the requirements. When no loader matches it will fall
|
// match the requirements. When no loader matches it will fall
|
||||||
// back to the "file" loader at the end of the loader list.
|
// back to the "file" loader at the end of the loader list.
|
||||||
oneOf: [
|
oneOf: [
|
||||||
|
|
||||||
// "url" loader works like "file" loader except that it embeds assets
|
// "url" loader works like "file" loader except that it embeds assets
|
||||||
// smaller than specified limit in bytes as data URLs to avoid requests.
|
// smaller than specified limit in bytes as data URLs to avoid requests.
|
||||||
// A missing `test` is equivalent to a match.
|
// A missing `test` is equivalent to a match.
|
||||||
|
@ -160,6 +180,7 @@ module.exports = {
|
||||||
name: 'static/media/[name].[hash:8].[ext]',
|
name: 'static/media/[name].[hash:8].[ext]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Process JS with Babel.
|
// Process JS with Babel.
|
||||||
{
|
{
|
||||||
test: /\.(js|jsx|mjs)$/,
|
test: /\.(js|jsx|mjs)$/,
|
||||||
|
@ -173,6 +194,7 @@ module.exports = {
|
||||||
cacheDirectory: true,
|
cacheDirectory: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// "postcss" loader applies autoprefixer to our CSS.
|
// "postcss" loader applies autoprefixer to our CSS.
|
||||||
// "css" loader resolves paths in CSS and adds assets as dependencies.
|
// "css" loader resolves paths in CSS and adds assets as dependencies.
|
||||||
// "style" loader turns CSS into JS modules that inject <style> tags.
|
// "style" loader turns CSS into JS modules that inject <style> tags.
|
||||||
|
@ -195,15 +217,17 @@ module.exports = {
|
||||||
test: /\.scss$/,
|
test: /\.scss$/,
|
||||||
use: ExtractTextPlugin.extract({
|
use: ExtractTextPlugin.extract({
|
||||||
fallback: 'style-loader',
|
fallback: 'style-loader',
|
||||||
use: ['css-loader', 'sass-loader', postCssLoader]
|
use: [ 'css-loader', 'sass-loader', postCssLoader ],
|
||||||
})
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// "file" loader makes sure those assets get served by WebpackDevServer.
|
// "file" loader makes sure those assets get served by WebpackDevServer.
|
||||||
// When you `import` an asset, you get its (virtual) filename.
|
// When you `import` an asset, you get its (virtual) filename.
|
||||||
// In production, they would get copied to the `build` folder.
|
// In production, they would get copied to the `build` folder.
|
||||||
// This loader doesn't use a "test" so it will catch all modules
|
// This loader doesn't use a "test" so it will catch all modules
|
||||||
// that fall through the other loaders.
|
// that fall through the other loaders.
|
||||||
{
|
{
|
||||||
|
|
||||||
// Exclude `js` files to keep "css" loader working as it injects
|
// Exclude `js` files to keep "css" loader working as it injects
|
||||||
// its runtime that would otherwise processed through "file" loader.
|
// its runtime that would otherwise processed through "file" loader.
|
||||||
// Also exclude `html` and `json` extensions so they get processed
|
// Also exclude `html` and `json` extensions so they get processed
|
||||||
|
@ -216,37 +240,46 @@ module.exports = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ** STOP ** Are you adding a new loader?
|
// ** STOP ** Are you adding a new loader?
|
||||||
// Make sure to add the new loader(s) before the "file" loader.
|
// Make sure to add the new loader(s) before the "file" loader.
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
||||||
// Makes some environment variables available in index.html.
|
// Makes some environment variables available in index.html.
|
||||||
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
|
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
|
||||||
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||||
// In development, this will be an empty string.
|
// In development, this will be an empty string.
|
||||||
new InterpolateHtmlPlugin(env.raw),
|
new InterpolateHtmlPlugin(env.raw),
|
||||||
|
|
||||||
// Generates an `index.html` file with the <script> injected.
|
// Generates an `index.html` file with the <script> injected.
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
inject: true,
|
inject: true,
|
||||||
template: paths.appHtml,
|
template: paths.appHtml,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Add module names to factory functions so they appear in browser profiler.
|
// Add module names to factory functions so they appear in browser profiler.
|
||||||
new webpack.NamedModulesPlugin(),
|
new webpack.NamedModulesPlugin(),
|
||||||
|
|
||||||
// Makes some environment variables available to the JS code, for example:
|
// Makes some environment variables available to the JS code, for example:
|
||||||
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
|
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
|
||||||
new webpack.DefinePlugin(env.stringified),
|
new webpack.DefinePlugin(env.stringified),
|
||||||
|
|
||||||
// This is necessary to emit hot updates (currently CSS only):
|
// This is necessary to emit hot updates (currently CSS only):
|
||||||
new webpack.HotModuleReplacementPlugin(),
|
new webpack.HotModuleReplacementPlugin(),
|
||||||
|
|
||||||
// Watcher doesn't work well if you mistype casing in a path so we use
|
// 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.
|
// a plugin that prints an error when you attempt to do this.
|
||||||
// See https://github.com/facebookincubator/create-react-app/issues/240
|
// See https://github.com/facebookincubator/create-react-app/issues/240
|
||||||
new CaseSensitivePathsPlugin(),
|
new CaseSensitivePathsPlugin(),
|
||||||
|
|
||||||
// If you require a missing module and then `npm install` it, you still have
|
// 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
|
// to restart the development server for Webpack to discover it. This plugin
|
||||||
// makes the discovery automatic so you don't have to restart.
|
// makes the discovery automatic so you don't have to restart.
|
||||||
// See https://github.com/facebookincubator/create-react-app/issues/186
|
// See https://github.com/facebookincubator/create-react-app/issues/186
|
||||||
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
|
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
|
||||||
|
|
||||||
// Moment.js is an extremely popular library that bundles large locale files
|
// 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
|
// by default due to how Webpack interprets its code. This is a practical
|
||||||
// solution that requires the user to opt into importing specific locales.
|
// solution that requires the user to opt into importing specific locales.
|
||||||
|
@ -255,6 +288,7 @@ module.exports = {
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
||||||
new ExtractTextPlugin('main.css'),
|
new ExtractTextPlugin('main.css'),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Some libraries import Node modules but don't use them in the browser.
|
// 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.
|
// Tell Webpack to provide empty mocks for them so importing them works.
|
||||||
node: {
|
node: {
|
||||||
|
@ -264,6 +298,7 @@ module.exports = {
|
||||||
tls: 'empty',
|
tls: 'empty',
|
||||||
child_process: 'empty',
|
child_process: 'empty',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Turn off performance hints during development because we don't do any
|
// Turn off performance hints during development because we don't do any
|
||||||
// splitting or minification in interest of speed. These warnings become
|
// splitting or minification in interest of speed. These warnings become
|
||||||
// cumbersome.
|
// cumbersome.
|
||||||
|
|
|
@ -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,15 +15,19 @@ 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);
|
||||||
|
|
||||||
|
@ -41,47 +44,57 @@ 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.
|
// Don't attempt to continue if there are any errors.
|
||||||
bail: true,
|
bail: true,
|
||||||
|
|
||||||
// We generate sourcemaps in production. This is slow but gives good results.
|
// We generate sourcemaps in production. This is slow but gives good results.
|
||||||
// You can exclude the *.map files from the build during deployment.
|
// You can exclude the *.map files from the build during deployment.
|
||||||
devtool: shouldUseSourceMap ? 'source-map' : false,
|
devtool: shouldUseSourceMap ? 'source-map' : false,
|
||||||
|
|
||||||
// In production, we only want to load the polyfills and the app code.
|
// In production, we only want to load the polyfills and the app code.
|
||||||
entry: [ require.resolve('./polyfills'), paths.appIndexJs ],
|
entry: [ require.resolve('./polyfills'), paths.appIndexJs ],
|
||||||
output: {
|
output: {
|
||||||
|
|
||||||
// The build folder.
|
// The build folder.
|
||||||
path: paths.appBuild,
|
path: paths.appBuild,
|
||||||
|
|
||||||
// Generated JS file names (with nested folders).
|
// Generated JS file names (with nested folders).
|
||||||
// There will be one main bundle, and one file per asynchronous chunk.
|
// There will be one main bundle, and one file per asynchronous chunk.
|
||||||
// We don't currently advertise code splitting but Webpack supports it.
|
// We don't currently advertise code splitting but Webpack supports it.
|
||||||
filename: 'static/js/[name].[chunkhash:8].js',
|
filename: 'static/js/[name].[chunkhash:8].js',
|
||||||
chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
|
chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
|
||||||
|
|
||||||
// We inferred the "public path" (such as / or /my-project) from homepage.
|
// We inferred the "public path" (such as / or /my-project) from homepage.
|
||||||
publicPath: publicPath,
|
publicPath,
|
||||||
|
|
||||||
// Point sourcemap entries to original disk location (format as URL on Windows)
|
// Point sourcemap entries to original disk location (format as URL on Windows)
|
||||||
devtoolModuleFilenameTemplate: info =>
|
devtoolModuleFilenameTemplate: (info) =>
|
||||||
path
|
path
|
||||||
.relative(paths.appSrc, info.absoluteResourcePath)
|
.relative(paths.appSrc, info.absoluteResourcePath)
|
||||||
.replace(/\\/g, '/'),
|
.replace(/\\/g, '/'),
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|
||||||
// This allows you to set a fallback for where Webpack should look for modules.
|
// 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"
|
// We placed these paths second because we want `node_modules` to "win"
|
||||||
// if there are any conflicts. This matches Node resolution mechanism.
|
// if there are any conflicts. This matches Node resolution mechanism.
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/253
|
// https://github.com/facebookincubator/create-react-app/issues/253
|
||||||
modules: [ 'node_modules', paths.appNodeModules ].concat(
|
modules: [ 'node_modules', paths.appNodeModules ].concat(
|
||||||
|
|
||||||
// It is guaranteed to exist because we tweak it in `env.js`
|
// It is guaranteed to exist because we tweak it in `env.js`
|
||||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
|
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
|
||||||
),
|
),
|
||||||
|
|
||||||
// These are the reasonable defaults supported by the Node ecosystem.
|
// These are the reasonable defaults supported by the Node ecosystem.
|
||||||
// We also include JSX as a common component filename extension to support
|
// We also include JSX as a common component filename extension to support
|
||||||
// some tools, although we do not recommend using it, see:
|
// some tools, although we do not recommend using it, see:
|
||||||
|
@ -96,6 +109,7 @@ module.exports = {
|
||||||
'react-native': 'react-native-web',
|
'react-native': 'react-native-web',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
||||||
// Prevents users from importing files from outside of src/ (or node_modules/).
|
// 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.
|
// 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,
|
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
|
||||||
|
@ -107,6 +121,7 @@ module.exports = {
|
||||||
module: {
|
module: {
|
||||||
strictExportPresence: true,
|
strictExportPresence: true,
|
||||||
rules: [
|
rules: [
|
||||||
|
|
||||||
// TODO: Disable require.ensure as it's not a standard language feature.
|
// 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.
|
// We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
|
||||||
// { parser: { requireEnsure: false } },
|
// { parser: { requireEnsure: false } },
|
||||||
|
@ -129,10 +144,12 @@ module.exports = {
|
||||||
include: paths.appSrc,
|
include: paths.appSrc,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
||||||
// "oneOf" will traverse all following loaders until one will
|
// "oneOf" will traverse all following loaders until one will
|
||||||
// match the requirements. When no loader matches it will fall
|
// match the requirements. When no loader matches it will fall
|
||||||
// back to the "file" loader at the end of the loader list.
|
// back to the "file" loader at the end of the loader list.
|
||||||
oneOf: [
|
oneOf: [
|
||||||
|
|
||||||
// "url" loader works just like "file" loader but it also embeds
|
// "url" loader works just like "file" loader but it also embeds
|
||||||
// assets smaller than specified size as data URLs to avoid requests.
|
// assets smaller than specified size as data URLs to avoid requests.
|
||||||
{
|
{
|
||||||
|
@ -143,6 +160,7 @@ module.exports = {
|
||||||
name: 'static/media/[name].[hash:8].[ext]',
|
name: 'static/media/[name].[hash:8].[ext]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Process JS with Babel.
|
// Process JS with Babel.
|
||||||
{
|
{
|
||||||
test: /\.(js|jsx|mjs)$/,
|
test: /\.(js|jsx|mjs)$/,
|
||||||
|
@ -153,6 +171,7 @@ module.exports = {
|
||||||
compact: true,
|
compact: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// The notation here is somewhat confusing.
|
// The notation here is somewhat confusing.
|
||||||
// "postcss" loader applies autoprefixer to our CSS.
|
// "postcss" loader applies autoprefixer to our CSS.
|
||||||
// "css" loader resolves paths in CSS and adds assets as dependencies.
|
// "css" loader resolves paths in CSS and adds assets as dependencies.
|
||||||
|
@ -191,6 +210,7 @@ module.exports = {
|
||||||
{
|
{
|
||||||
loader: require.resolve('postcss-loader'),
|
loader: require.resolve('postcss-loader'),
|
||||||
options: {
|
options: {
|
||||||
|
|
||||||
// Necessary for external CSS imports to work
|
// Necessary for external CSS imports to work
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/2677
|
// https://github.com/facebookincubator/create-react-app/issues/2677
|
||||||
ident: 'postcss',
|
ident: 'postcss',
|
||||||
|
@ -201,7 +221,7 @@ module.exports = {
|
||||||
'>1%',
|
'>1%',
|
||||||
'last 4 versions',
|
'last 4 versions',
|
||||||
'Firefox ESR',
|
'Firefox ESR',
|
||||||
'not ie < 9', // React doesn't support IE8 anyway
|
'not ie < 9',
|
||||||
],
|
],
|
||||||
flexbox: 'no-2009',
|
flexbox: 'no-2009',
|
||||||
}),
|
}),
|
||||||
|
@ -213,14 +233,17 @@ module.exports = {
|
||||||
extractTextPluginOptions
|
extractTextPluginOptions
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
|
// Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
|
||||||
},
|
},
|
||||||
|
|
||||||
// "file" loader makes sure assets end up in the `build` folder.
|
// "file" loader makes sure assets end up in the `build` folder.
|
||||||
// When you `import` an asset, you get its filename.
|
// When you `import` an asset, you get its filename.
|
||||||
// This loader doesn't use a "test" so it will catch all modules
|
// This loader doesn't use a "test" so it will catch all modules
|
||||||
// that fall through the other loaders.
|
// that fall through the other loaders.
|
||||||
{
|
{
|
||||||
loader: require.resolve('file-loader'),
|
loader: require.resolve('file-loader'),
|
||||||
|
|
||||||
// Exclude `js` files to keep "css" loader working as it injects
|
// Exclude `js` files to keep "css" loader working as it injects
|
||||||
// it's runtime that would otherwise processed through "file" loader.
|
// it's runtime that would otherwise processed through "file" loader.
|
||||||
// Also exclude `html` and `json` extensions so they get processed
|
// Also exclude `html` and `json` extensions so they get processed
|
||||||
|
@ -230,6 +253,7 @@ module.exports = {
|
||||||
name: 'static/media/[name].[hash:8].[ext]',
|
name: 'static/media/[name].[hash:8].[ext]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ** STOP ** Are you adding a new loader?
|
// ** STOP ** Are you adding a new loader?
|
||||||
// Make sure to add the new loader(s) before the "file" loader.
|
// Make sure to add the new loader(s) before the "file" loader.
|
||||||
],
|
],
|
||||||
|
@ -237,12 +261,14 @@ module.exports = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
||||||
// Makes some environment variables available in index.html.
|
// Makes some environment variables available in index.html.
|
||||||
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
|
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
|
||||||
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||||
// In production, it will be an empty string unless you specify "homepage"
|
// In production, it will be an empty string unless you specify "homepage"
|
||||||
// in `package.json`, in which case it will be the pathname of that URL.
|
// in `package.json`, in which case it will be the pathname of that URL.
|
||||||
new InterpolateHtmlPlugin(env.raw),
|
new InterpolateHtmlPlugin(env.raw),
|
||||||
|
|
||||||
// Generates an `index.html` file with the <script> injected.
|
// Generates an `index.html` file with the <script> injected.
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
inject: true,
|
inject: true,
|
||||||
|
@ -260,15 +286,18 @@ module.exports = {
|
||||||
minifyURLs: true,
|
minifyURLs: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Makes some environment variables available to the JS code, for example:
|
// Makes some environment variables available to the JS code, for example:
|
||||||
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
|
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
|
||||||
// It is absolutely essential that NODE_ENV was set to production here.
|
// It is absolutely essential that NODE_ENV was set to production here.
|
||||||
// Otherwise React will be compiled in the very slow development mode.
|
// Otherwise React will be compiled in the very slow development mode.
|
||||||
new webpack.DefinePlugin(env.stringified),
|
new webpack.DefinePlugin(env.stringified),
|
||||||
|
|
||||||
// Minify the code.
|
// Minify the code.
|
||||||
new webpack.optimize.UglifyJsPlugin({
|
new webpack.optimize.UglifyJsPlugin({
|
||||||
compress: {
|
compress: {
|
||||||
warnings: false,
|
warnings: false,
|
||||||
|
|
||||||
// Disabled because of an issue with Uglify breaking seemingly valid code:
|
// Disabled because of an issue with Uglify breaking seemingly valid code:
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/2376
|
// https://github.com/facebookincubator/create-react-app/issues/2376
|
||||||
// Pending further investigation:
|
// Pending further investigation:
|
||||||
|
@ -280,25 +309,30 @@ module.exports = {
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
comments: false,
|
comments: false,
|
||||||
|
|
||||||
// Turned on because emoji and regex is not minified properly using default
|
// Turned on because emoji and regex is not minified properly using default
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/2488
|
// https://github.com/facebookincubator/create-react-app/issues/2488
|
||||||
ascii_only: true,
|
ascii_only: true,
|
||||||
},
|
},
|
||||||
sourceMap: shouldUseSourceMap,
|
sourceMap: shouldUseSourceMap,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
|
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
|
||||||
new ExtractTextPlugin({
|
new ExtractTextPlugin({
|
||||||
filename: cssFilename,
|
filename: cssFilename,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Generate a manifest file which contains a mapping of all asset filenames
|
// 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
|
// to their corresponding output file so that tools can pick it up without
|
||||||
// having to parse `index.html`.
|
// having to parse `index.html`.
|
||||||
new ManifestPlugin({
|
new ManifestPlugin({
|
||||||
fileName: 'asset-manifest.json',
|
fileName: 'asset-manifest.json',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Generate a service worker script that will precache, and keep up to date,
|
// Generate a service worker script that will precache, and keep up to date,
|
||||||
// the HTML & assets that are part of the Webpack build.
|
// the HTML & assets that are part of the Webpack build.
|
||||||
new SWPrecacheWebpackPlugin({
|
new SWPrecacheWebpackPlugin({
|
||||||
|
|
||||||
// By default, a cache-busting query parameter is appended to requests
|
// By default, a cache-busting query parameter is appended to requests
|
||||||
// used to populate the caches, to ensure the responses are fresh.
|
// used to populate the caches, to ensure the responses are fresh.
|
||||||
// If a URL is already hashed by Webpack, then there is no concern
|
// If a URL is already hashed by Webpack, then there is no concern
|
||||||
|
@ -315,17 +349,22 @@ module.exports = {
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/2612
|
// https://github.com/facebookincubator/create-react-app/issues/2612
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(message);
|
|
||||||
|
console.log(message); // eslint-disable-line
|
||||||
},
|
},
|
||||||
minify: true,
|
minify: true,
|
||||||
|
|
||||||
// For unknown URLs, fallback to the index page
|
// For unknown URLs, fallback to the index page
|
||||||
navigateFallback: publicUrl + '/index.html',
|
navigateFallback: `${publicUrl}/index.html`,
|
||||||
|
|
||||||
// Ignores URLs starting from /__ (useful for Firebase):
|
// Ignores URLs starting from /__ (useful for Firebase):
|
||||||
// https://github.com/facebookincubator/create-react-app/issues/2237#issuecomment-302693219
|
// https://github.com/facebookincubator/create-react-app/issues/2237#issuecomment-302693219
|
||||||
navigateFallbackWhitelist: [ /^(?!\/__).*/ ],
|
navigateFallbackWhitelist: [ /^(?!\/__).*/ ],
|
||||||
|
|
||||||
// Don't precache sourcemaps (they're large) and build asset manifest:
|
// Don't precache sourcemaps (they're large) and build asset manifest:
|
||||||
staticFileGlobsIgnorePatterns: [ /\.map$/, /asset-manifest\.json$/ ],
|
staticFileGlobsIgnorePatterns: [ /\.map$/, /asset-manifest\.json$/ ],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Moment.js is an extremely popular library that bundles large locale files
|
// 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
|
// by default due to how Webpack interprets its code. This is a practical
|
||||||
// solution that requires the user to opt into importing specific locales.
|
// solution that requires the user to opt into importing specific locales.
|
||||||
|
@ -333,6 +372,7 @@ module.exports = {
|
||||||
// You can remove this if you don't use Moment.js:
|
// You can remove this if you don't use Moment.js:
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Some libraries import Node modules but don't use them in the browser.
|
// 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.
|
// Tell Webpack to provide empty mocks for them so importing them works.
|
||||||
node: {
|
node: {
|
||||||
|
|
|
@ -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 won’t automatically be available in
|
// This is confusing because those files won’t 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
|
||||||
|
|
|
@ -10,3 +10,4 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
- "56745:56745"
|
- "56745:56745"
|
||||||
|
- "5000:5000"
|
||||||
|
|
45
package.json
45
package.json
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,23 +18,21 @@ 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 ])) {
|
||||||
|
@ -44,12 +42,14 @@ if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
|
||||||
// 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'));
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -40,7 +40,8 @@ if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -6,10 +6,9 @@ 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">
|
<div className="container-fluid app-container">
|
||||||
<MainHeader />
|
<MainHeader />
|
||||||
|
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
@ -22,4 +21,3 @@ export default class App extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
@import './utils/base';
|
@import './utils/base';
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
padding-top: $headerHeight;
|
padding-top: $headerHeight;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,29 +1,45 @@
|
||||||
@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;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
|
padding-top: 13px;
|
||||||
|
padding-bottom: 10px;
|
||||||
top: $headerHeight;
|
top: $headerHeight;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1000;
|
|
||||||
display: block;
|
display: block;
|
||||||
padding: 30px 15px 15px;
|
z-index: 1010;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
padding: 30px 15px 15px;
|
||||||
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 {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,16 +29,30 @@ 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
|
||||||
|
tag={Link}
|
||||||
|
to={createServerPath}
|
||||||
|
active={location.pathname === createServerPath}
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
@ -43,4 +64,8 @@ export class MainHeader extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(MainHeader);
|
MainHeaderComponent.propTypes = propTypes;
|
||||||
|
|
||||||
|
const MainHeader = withRouter(MainHeaderComponent);
|
||||||
|
|
||||||
|
export default MainHeader;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -1,27 +1,80 @@
|
||||||
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}
|
||||||
|
className={burgerClasses}
|
||||||
|
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
|
||||||
|
onClick={() => this.setState({ showSideBar: false })}
|
||||||
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
|
@ -38,11 +91,25 @@ export class MenuLayout extends React.Component {
|
||||||
path="/server/:serverId/short-code/:shortCode/visits"
|
path="/server/:serverId/short-code/:shortCode/visits"
|
||||||
component={ShortUrlsVisits}
|
component={ShortUrlsVisits}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path="/server/:serverId/manage-tags"
|
||||||
|
component={TagsList}
|
||||||
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
||||||
|
|
34
src/common/MenuLayout.scss
Normal file
34
src/common/MenuLayout.scss
Normal 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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
||||||
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, -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 {
|
||||||
|
|
|
@ -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__
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -12,3 +12,9 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-server__csv-select {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
<DropdownItem
|
||||||
|
key={id}
|
||||||
tag={Link}
|
tag={Link}
|
||||||
to={`/server/${id}/list-short-urls/1`}
|
to={`/server/${id}/list-short-urls/1`}
|
||||||
active={selectedServer && selectedServer.id === id}
|
active={selectedServer && selectedServer.id === id}
|
||||||
onClick={() => selectServer(id)} // FIXME This should be implicit
|
|
||||||
|
// FIXME This should be implicit
|
||||||
|
onClick={() => selectServer(id)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</span>
|
))}
|
||||||
));
|
<DropdownItem divider />
|
||||||
|
<DropdownItem
|
||||||
|
className="servers-dropdown__export-item"
|
||||||
|
onClick={() => serversExporter.exportServers()}
|
||||||
|
>
|
||||||
|
Export servers
|
||||||
|
</DropdownItem>
|
||||||
|
</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;
|
||||||
|
|
66
src/servers/helpers/ImportServersBtn.js
Normal file
66
src/servers/helpers/ImportServersBtn.js
Normal 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;
|
8
src/servers/prop-types/index.js
Normal file
8
src/servers/prop-types/index.js
Normal 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,
|
||||||
|
});
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
55
src/servers/services/ServersExporter.js
Normal file
55
src/servers/services/ServersExporter.js
Normal 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;
|
34
src/servers/services/ServersImporter.js
Normal file
34
src/servers/services/ServersImporter.js
Normal 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;
|
|
@ -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;
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
|
@ -1,20 +1,26 @@
|
||||||
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({
|
||||||
|
currentPage: PropTypes.number,
|
||||||
|
pagesCount: PropTypes.number,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Paginator({ paginator = {}, serverId }) {
|
||||||
const { currentPage, pagesCount = 0 } = paginator;
|
const { currentPage, pagesCount = 0 } = paginator;
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPages = () => {
|
const renderPages = () =>
|
||||||
const pages = [];
|
range(1, pagesCount + 1).map((i) => (
|
||||||
|
|
||||||
for (let i = 1; i <= pagesCount; i++) {
|
|
||||||
pages.push(
|
|
||||||
<PaginationItem key={i} active={currentPage === i}>
|
<PaginationItem key={i} active={currentPage === i}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
tag={Link}
|
tag={Link}
|
||||||
|
@ -23,11 +29,7 @@ export default class Paginator extends React.Component {
|
||||||
{i}
|
{i}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
);
|
));
|
||||||
}
|
|
||||||
|
|
||||||
return pages;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination listClassName="flex-wrap">
|
<Pagination listClassName="flex-wrap">
|
||||||
|
@ -49,4 +51,5 @@ export default class Paginator extends React.Component {
|
||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Paginator.propTypes = propTypes;
|
||||||
|
|
|
@ -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"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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" />
|
||||||
|
|
||||||
{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) {
|
SearchBarComponent.propTypes = propTypes;
|
||||||
this.setState({
|
|
||||||
showClearBtn: searchTerm !== '',
|
|
||||||
searchTerm: searchTerm,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetTimer = () => {
|
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent);
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
};
|
|
||||||
resetTimer();
|
|
||||||
|
|
||||||
this.timer = setTimeout(() => {
|
export default SearchBar;
|
||||||
this.props.listShortUrls({ ...this.props.shortUrlsListParams, searchTerm });
|
|
||||||
resetTimer();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(pick(['shortUrlsListParams']), { listShortUrls })(SearchBar);
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 && (
|
||||||
|
<Doughnut
|
||||||
|
data={generateGraphData(stats, label || title, isBarChart)}
|
||||||
|
options={{
|
||||||
legend: {
|
legend: {
|
||||||
position: 'right'
|
position: 'right',
|
||||||
}
|
},
|
||||||
}} />}
|
}}
|
||||||
{isBarChart && <HorizontalBar data={generateGraphData(stats, label || title, isBarChart)} options={{
|
/>
|
||||||
|
)}
|
||||||
|
{isBarChart && (
|
||||||
|
<HorizontalBar
|
||||||
|
data={generateGraphData(stats, label || title, isBarChart)}
|
||||||
|
options={{
|
||||||
legend: {
|
legend: {
|
||||||
display: false
|
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 && (
|
||||||
|
<div>
|
||||||
Created:
|
Created:
|
||||||
|
|
||||||
{loading && <small>Loading...</small>}
|
{loading && <small>Loading...</small>}
|
||||||
{!loading && renderCreated()}
|
{!loading && renderCreated()}
|
||||||
</div>}
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
Long URL:
|
Long URL:
|
||||||
|
|
||||||
{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;
|
||||||
|
|
|
@ -2,6 +2,6 @@
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,21 +75,29 @@ 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) {
|
||||||
|
@ -82,7 +108,7 @@ export class ShortUrlsList extends React.Component {
|
||||||
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;
|
||||||
|
|
|
@ -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,7 +49,11 @@ 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
|
||||||
|
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||||
|
id="copyBtn"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={copyIcon} /> Copy
|
<FontAwesomeIcon icon={copyIcon} /> Copy
|
||||||
</button>
|
</button>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
@ -51,4 +65,6 @@ export default class CreateShortUrlResult extends React.Component {
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
CreateShortUrlResult.propTypes = propTypes;
|
||||||
|
|
100
src/short-urls/helpers/EditTagsModal.js
Normal file
100
src/short-urls/helpers/EditTagsModal.js
Normal 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;
|
|
@ -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';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
url: PropTypes.string,
|
||||||
|
toggle: PropTypes.func,
|
||||||
|
isOpen: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
export default function PreviewModal({ url, toggle, isOpen }) {
|
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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
url: PropTypes.string,
|
||||||
|
toggle: PropTypes.func,
|
||||||
|
isOpen: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
export default function QrCodeModal({ url, toggle, isOpen }) {
|
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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,19 +9,36 @@ 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">
|
||||||
|
@ -28,9 +46,18 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={menuIcon} />
|
||||||
</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} /> Visit Stats
|
<FontAwesomeIcon icon={pieChartIcon} /> Visit Stats
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={toggleTags}>
|
||||||
|
<FontAwesomeIcon icon={tagsIcon} /> Edit tags
|
||||||
|
</DropdownItem>
|
||||||
|
<EditTagsModal
|
||||||
|
url={completeShortUrl}
|
||||||
|
shortUrl={shortUrl}
|
||||||
|
isOpen={this.state.isTagsModalOpen}
|
||||||
|
toggle={toggleTags}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
|
@ -38,7 +65,7 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||||
<FontAwesomeIcon icon={pictureIcon} /> Preview
|
<FontAwesomeIcon icon={pictureIcon} /> Preview
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<PreviewModal
|
<PreviewModal
|
||||||
url={shortUrl}
|
url={completeShortUrl}
|
||||||
isOpen={this.state.isPreviewOpen}
|
isOpen={this.state.isPreviewOpen}
|
||||||
toggle={togglePreview}
|
toggle={togglePreview}
|
||||||
/>
|
/>
|
||||||
|
@ -47,14 +74,14 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||||
<FontAwesomeIcon icon={qrIcon} /> QR code
|
<FontAwesomeIcon icon={qrIcon} /> 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} /> Copy to clipboard
|
<FontAwesomeIcon icon={copyIcon} /> Copy to clipboard
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
@ -64,3 +91,5 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ShortUrlsRowMenu.propTypes = propTypes;
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
77
src/short-urls/reducers/shortUrlTags.js
Normal file
77
src/short-urls/reducers/shortUrlTags.js
Normal 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,
|
||||||
|
});
|
|
@ -1,15 +1,26 @@
|
||||||
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) {
|
||||||
|
@ -17,34 +28,35 @@ export default function dispatch (state = initialState, action) {
|
||||||
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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
74
src/tags/TagCard.js
Normal 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
36
src/tags/TagCard.scss
Normal 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
92
src/tags/TagsList.js
Normal 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;
|
78
src/tags/helpers/DeleteTagConfirmModal.js
Normal file
78
src/tags/helpers/DeleteTagConfirmModal.js
Normal 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;
|
141
src/tags/helpers/EditTagModal.js
Normal file
141
src/tags/helpers/EditTagModal.js
Normal 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;
|
7
src/tags/helpers/EditTagModal.scss
Normal file
7
src/tags/helpers/EditTagModal.scss
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.edit-tag-modal__color-picker-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tag-modal__color-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
59
src/tags/reducers/tagDelete.js
Normal file
59
src/tags/reducers/tagDelete.js
Normal 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 });
|
67
src/tags/reducers/tagEdit.js
Normal file
67
src/tags/reducers/tagEdit.js
Normal 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,
|
||||||
|
});
|
88
src/tags/reducers/tagsList.js
Normal file
88
src/tags/reducers/tagsList.js
Normal 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,
|
||||||
|
});
|
|
@ -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 { floor, random } = Math;
|
||||||
const letters = '0123456789ABCDEF';
|
const letters = '0123456789ABCDEF';
|
||||||
let color = '#';
|
const buildRandomColor = () =>
|
||||||
for (let i = 0; i < 6; i++ ) {
|
`#${
|
||||||
color += letters[Math.floor(Math.random() * 16)];
|
range(0, HEX_COLOR_LENGTH)
|
||||||
}
|
.map(() => letters[floor(random() * letters.length)])
|
||||||
return color;
|
.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 color;
|
return this.getColorForKey(key);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ColorGenerator(Storage);
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
setColorForKey = (key, color) => {
|
||||||
|
this.colors[key] = color;
|
||||||
|
this.storage.set('colors', this.colors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
19
src/utils/ExternalLink.js
Normal 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;
|
28
src/utils/MuttedMessage.js
Normal file
28
src/utils/MuttedMessage.js
Normal 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
69
src/utils/SearchField.js
Normal 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)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField.propTypes = propTypes;
|
||||||
|
SearchField.defaultProps = defaultProps;
|
23
src/utils/SearchField.scss
Normal file
23
src/utils/SearchField.scss
Normal 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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
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(
|
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}>×</span>}
|
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>×</span>}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Tag.defaultProps = {
|
Tag.defaultProps = defaultProps;
|
||||||
colorGenerator: ColorGenerator
|
Tag.propTypes = propTypes;
|
||||||
};
|
|
||||||
|
|
44
src/utils/TagsSelector.js
Normal file
44
src/utils/TagsSelector.js
Normal 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;
|
|
@ -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
27
test/App.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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({
|
||||||
data: { token: 'foo', ...extraData },
|
headers: { authorization: 'Bearer abc123' },
|
||||||
headers: { authorization: 'Bearer abc123' }
|
data: { token: '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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
|
|
66
test/servers/CreateServer.test.js
Normal file
66
test/servers/CreateServer.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
34
test/servers/DeleteServerButton.test.js
Normal file
34
test/servers/DeleteServerButton.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
65
test/servers/DeleteServerModal.test.js
Normal file
65
test/servers/DeleteServerModal.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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...');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
61
test/servers/helpers/ImportServersBtn.test.js
Normal file
61
test/servers/helpers/ImportServersBtn.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
97
test/servers/services/ServersExporter.test.js
Normal file
97
test/servers/services/ServersExporter.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
47
test/servers/services/ServersImporter.test.js
Normal file
47
test/servers/services/ServersImporter.test.js
Normal 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
Loading…
Reference in a new issue