mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #296 from acelaya-forks/feature/typescript
Feature/typescript
This commit is contained in:
commit
90abf29db9
309 changed files with 14016 additions and 7967 deletions
45
.eslintrc
45
.eslintrc
|
@ -1,45 +1,28 @@
|
||||||
{
|
{
|
||||||
"extends": [
|
"extends": [
|
||||||
"adidas-env/browser",
|
"@shlinkio/js-coding-standard"
|
||||||
"adidas-env/module",
|
|
||||||
"adidas-env/node",
|
|
||||||
"adidas-es6",
|
|
||||||
"adidas-babel",
|
|
||||||
"adidas-react"
|
|
||||||
],
|
],
|
||||||
"plugins": ["jest"],
|
"plugins": ["jest"],
|
||||||
"env": {
|
"env": {
|
||||||
"jest/globals": true
|
"jest/globals": true
|
||||||
},
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"tsconfigRootDir": ".",
|
||||||
|
"createDefaultProgram": true
|
||||||
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"process": true,
|
"process": true,
|
||||||
"setImmediate": true
|
"setImmediate": true
|
||||||
},
|
},
|
||||||
"settings": {
|
|
||||||
"react": {
|
|
||||||
"version": "16.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rules": {
|
"rules": {
|
||||||
"comma-dangle": ["error", "always-multiline"],
|
"max-len": ["error", {
|
||||||
"no-invalid-this": "off",
|
"code": 120,
|
||||||
"no-console": "warn",
|
"ignoreStrings": true,
|
||||||
"template-curly-spacing": ["error", "never"],
|
"ignoreTemplateLiterals": true,
|
||||||
"no-warning-comments": "off",
|
"ignoreComments": true
|
||||||
"no-magic-numbers": "off",
|
}],
|
||||||
"no-undefined": "off",
|
"no-mixed-operators": "off",
|
||||||
"no-inline-comments": "off",
|
"react/display-name": "off",
|
||||||
"lines-around-comment": "off",
|
"@typescript-eslint/require-array-sort-compare": "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",
|
|
||||||
"react/display-name": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ install:
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
||||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
|
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
||||||
|
|
||||||
script:
|
script:
|
||||||
- npm run lint
|
- npm run lint
|
||||||
|
|
|
@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|
||||||
#### Changed
|
#### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* [#40](https://github.com/shlinkio/shlink-web-client/issues/40) Migrated project to TypeScript.
|
||||||
|
|
||||||
#### Deprecated
|
#### Deprecated
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
|
|
||||||
* *Nothing*
|
* [#295](https://github.com/shlinkio/shlink-web-client/issues/295) Fixed custom slug field not being disabled when selecting a short code length.
|
||||||
|
|
||||||
|
|
||||||
## 2.5.1 - 2020-06-06
|
## 2.5.1 - 2020-06-06
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
@ -10,7 +11,7 @@ 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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +37,7 @@ dotenvFiles.forEach((dotenvFile) => {
|
||||||
require('dotenv-expand')(
|
require('dotenv-expand')(
|
||||||
require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: dotenvFile,
|
path: dotenvFile,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -82,7 +83,7 @@ function getClientEnvironment(publicUrl) {
|
||||||
// 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
|
||||||
// images into the `src` and `import` them in code to get their paths.
|
// images into the `src` and `import` them in code to get their paths.
|
||||||
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
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
|
@ -75,7 +75,7 @@ module.exports = (webpackEnv) => {
|
||||||
loader: MiniCssExtractPlugin.loader,
|
loader: MiniCssExtractPlugin.loader,
|
||||||
options: Object.assign(
|
options: Object.assign(
|
||||||
{},
|
{},
|
||||||
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
|
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -227,7 +227,7 @@ module.exports = (webpackEnv) => {
|
||||||
|
|
||||||
// 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/facebook/create-react-app/issues/2488
|
// https://github.com/facebook/create-react-app/issues/2488
|
||||||
ascii_only: true,
|
ascii_only: true, // eslint-disable-line @typescript-eslint/camelcase
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -281,7 +281,7 @@ module.exports = (webpackEnv) => {
|
||||||
modules: [ 'node_modules' ].concat(
|
modules: [ 'node_modules' ].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.
|
||||||
|
@ -372,7 +372,7 @@ module.exports = (webpackEnv) => {
|
||||||
loader: require.resolve('babel-loader'),
|
loader: require.resolve('babel-loader'),
|
||||||
options: {
|
options: {
|
||||||
customize: require.resolve(
|
customize: require.resolve(
|
||||||
'babel-preset-react-app/webpack-overrides'
|
'babel-preset-react-app/webpack-overrides',
|
||||||
),
|
),
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
|
@ -470,7 +470,7 @@ module.exports = (webpackEnv) => {
|
||||||
importLoaders: 2,
|
importLoaders: 2,
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
sourceMap: isEnvProduction && shouldUseSourceMap,
|
||||||
},
|
},
|
||||||
'sass-loader'
|
'sass-loader',
|
||||||
),
|
),
|
||||||
|
|
||||||
// Don't consider CSS imports dead code even if the
|
// Don't consider CSS imports dead code even if the
|
||||||
|
@ -491,7 +491,7 @@ module.exports = (webpackEnv) => {
|
||||||
modules: true,
|
modules: true,
|
||||||
getLocalIdent: getCSSModuleLocalIdent,
|
getLocalIdent: getCSSModuleLocalIdent,
|
||||||
},
|
},
|
||||||
'sass-loader'
|
'sass-loader',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -544,8 +544,8 @@ module.exports = (webpackEnv) => {
|
||||||
minifyURLs: true,
|
minifyURLs: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined,
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Inlines the webpack runtime script. This script is too small to warrant
|
// Inlines the webpack runtime script. This script is too small to warrant
|
||||||
|
@ -668,7 +668,7 @@ module.exports = (webpackEnv) => {
|
||||||
fs: 'empty',
|
fs: 'empty',
|
||||||
net: 'empty',
|
net: 'empty',
|
||||||
tls: 'empty',
|
tls: 'empty',
|
||||||
child_process: 'empty',
|
child_process: 'empty', // eslint-disable-line @typescript-eslint/camelcase
|
||||||
},
|
},
|
||||||
|
|
||||||
// Turn off performance processing because we utilize
|
// Turn off performance processing because we utilize
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
|
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
coverageDirectory: '<rootDir>/coverage',
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.js',
|
'src/**/*.{js,ts,tsx}',
|
||||||
'!src/registerServiceWorker.js',
|
'!src/registerServiceWorker.js',
|
||||||
'!src/index.js',
|
'!src/index.ts',
|
||||||
'!src/reducers/index.js',
|
'!src/reducers/index.ts',
|
||||||
'!src/**/provideServices.js',
|
'!src/**/provideServices.ts',
|
||||||
'!src/container/*.js',
|
'!src/container/*.ts',
|
||||||
],
|
],
|
||||||
resolver: 'jest-pnp-resolver',
|
resolver: 'jest-pnp-resolver',
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
|
@ -17,9 +17,9 @@ module.exports = {
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
testURL: 'http://localhost',
|
testURL: 'http://localhost',
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||||
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
||||||
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
||||||
|
|
10488
package-lock.json
generated
10488
package-lock.json
generated
File diff suppressed because it is too large
Load diff
128
package.json
128
package.json
|
@ -7,7 +7,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:js && npm run lint:css",
|
"lint": "npm run lint:js && npm run lint:css",
|
||||||
"lint:js": "eslint src test scripts config",
|
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||||
"lint:css:fix": "npm run lint:css -- --fix",
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
|
@ -22,61 +22,81 @@
|
||||||
"check": "npm run test & npm run lint & wait"
|
"check": "npm run test & npm run lint & wait"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.11.2",
|
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.11.2",
|
"@fortawesome/free-regular-svg-icons": "^5.14.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
"@fortawesome/react-fontawesome": "^0.1.11",
|
||||||
"array-filter": "^1.0.0",
|
"array-filter": "^1.0.0",
|
||||||
"array-map": "^0.0.0",
|
"array-map": "^0.0.0",
|
||||||
"array-reduce": "^0.0.0",
|
"array-reduce": "^0.0.0",
|
||||||
"axios": "^0.19.0",
|
"axios": "^0.20.0",
|
||||||
"bootstrap": "^4.3.1",
|
"bootstrap": "^4.5.2",
|
||||||
"bottlejs": "^1.7.2",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.9.0",
|
"bowser": "^2.10.0",
|
||||||
"chart.js": "^2.8.0",
|
"chart.js": "^2.9.3",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"compare-versions": "^3.5.1",
|
"compare-versions": "^3.6.0",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
"event-source-polyfill": "^1.0.12",
|
"event-source-polyfill": "^1.0.17",
|
||||||
"leaflet": "^1.5.1",
|
"leaflet": "^1.7.1",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.27.0",
|
||||||
"promise": "^8.0.3",
|
"promise": "^8.0.3",
|
||||||
"prop-types": "^15.7.2",
|
"qs": "^6.9.4",
|
||||||
"qs": "^6.9.0",
|
"ramda": "^0.27.1",
|
||||||
"ramda": "^0.26.1",
|
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-autosuggest": "^9.4.3",
|
"react-autosuggest": "^10.0.2",
|
||||||
"react-chartjs-2": "^2.8.0",
|
"react-chartjs-2": "^2.10.0",
|
||||||
"react-color": "^2.17.3",
|
"react-color": "^2.18.1",
|
||||||
"react-copy-to-clipboard": "^5.0.1",
|
"react-copy-to-clipboard": "^5.0.2",
|
||||||
"react-datepicker": "~1.5.0",
|
"react-datepicker": "~1.5.0",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
"react-external-link": "^1.0.0",
|
"react-external-link": "^1.1.1",
|
||||||
"react-leaflet": "^2.4.0",
|
"react-leaflet": "^2.7.0",
|
||||||
"react-moment": "^0.9.5",
|
"react-moment": "^0.9.7",
|
||||||
"react-redux": "^7.1.1",
|
"react-redux": "^7.2.1",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-swipeable": "^5.4.0",
|
"react-swipeable": "^5.5.1",
|
||||||
"react-tagsinput": "^3.19.0",
|
"react-tagsinput": "^3.19.0",
|
||||||
"reactstrap": "^8.0.1",
|
"reactstrap": "^8.0.1",
|
||||||
"redux": "^4.0.4",
|
"redux": "^4.0.4",
|
||||||
"redux-actions": "^2.6.5",
|
|
||||||
"redux-localstorage-simple": "^2.2.0",
|
"redux-localstorage-simple": "^2.2.0",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"uuid": "^3.3.3"
|
"uuid": "^3.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.6.2",
|
"@babel/core": "^7.6.2",
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
|
||||||
|
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
|
||||||
|
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
|
||||||
"@stryker-mutator/core": "^3.2.4",
|
"@stryker-mutator/core": "^3.2.4",
|
||||||
"@stryker-mutator/javascript-mutator": "^3.2.4",
|
"@stryker-mutator/typescript": "^3.2.4",
|
||||||
"@stryker-mutator/jest-runner": "^3.2.4",
|
"@stryker-mutator/jest-runner": "^3.2.4",
|
||||||
"@svgr/webpack": "^4.3.3",
|
"@svgr/webpack": "^4.3.3",
|
||||||
|
"@types/chart.js": "^2.9.24",
|
||||||
|
"@types/classnames": "^2.2.10",
|
||||||
|
"@types/enzyme": "^3.10.5",
|
||||||
|
"@types/jest": "^26.0.10",
|
||||||
|
"@types/leaflet": "^1.5.17",
|
||||||
|
"@types/moment": "^2.13.0",
|
||||||
|
"@types/qs": "^6.9.4",
|
||||||
|
"@types/ramda": "^0.27.14",
|
||||||
|
"@types/react": "^16.9.46",
|
||||||
|
"@types/react-autosuggest": "^10.0.0",
|
||||||
|
"@types/react-color": "^2.17.4",
|
||||||
|
"@types/react-copy-to-clipboard": "^4.3.0",
|
||||||
|
"@types/react-datepicker": "~1.8.0",
|
||||||
|
"@types/react-dom": "^16.9.8",
|
||||||
|
"@types/react-leaflet": "^2.5.2",
|
||||||
|
"@types/react-redux": "^7.1.9",
|
||||||
|
"@types/react-router-dom": "^5.1.5",
|
||||||
|
"@types/react-tagsinput": "^3.19.7",
|
||||||
|
"@types/reactstrap": "^8.5.1",
|
||||||
|
"@types/uuid": "^8.3.0",
|
||||||
"adm-zip": "^0.4.13",
|
"adm-zip": "^0.4.13",
|
||||||
"autoprefixer": "^9.6.3",
|
"autoprefixer": "^9.6.3",
|
||||||
"babel-core": "7.0.0-bridge.0",
|
"babel-core": "7.0.0-bridge.0",
|
||||||
"babel-eslint": "^10.0.3",
|
"babel-jest": "^26.3.0",
|
||||||
"babel-jest": "^24.9.0",
|
|
||||||
"babel-loader": "^8.0.6",
|
"babel-loader": "^8.0.6",
|
||||||
"babel-plugin-named-asset-import": "^0.3.4",
|
"babel-plugin-named-asset-import": "^0.3.4",
|
||||||
"babel-preset-react-app": "^9.0.2",
|
"babel-preset-react-app": "^9.0.2",
|
||||||
|
@ -89,27 +109,18 @@
|
||||||
"dotenv-expand": "^5.1.0",
|
"dotenv-expand": "^5.1.0",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"enzyme-adapter-react-16": "^1.15.2",
|
"enzyme-adapter-react-16": "^1.15.2",
|
||||||
"eslint": "^5.11.1",
|
"eslint": "^6.8.0",
|
||||||
"eslint-config-adidas-babel": "^1.1.0",
|
|
||||||
"eslint-config-adidas-env": "^1.1.0",
|
|
||||||
"eslint-config-adidas-es6": "^1.2.0",
|
|
||||||
"eslint-config-adidas-react": "^1.1.1",
|
|
||||||
"eslint-loader": "^3.0.2",
|
"eslint-loader": "^3.0.2",
|
||||||
"eslint-plugin-import": "^2.18.2",
|
|
||||||
"eslint-plugin-jest": "^22.17.0",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
|
||||||
"eslint-plugin-promise": "^4.2.1",
|
|
||||||
"eslint-plugin-react": "^7.16.0",
|
|
||||||
"file-loader": "^4.2.0",
|
"file-loader": "^4.2.0",
|
||||||
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
||||||
"fs-extra": "^8.1.0",
|
"fs-extra": "^8.1.0",
|
||||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
"html-webpack-plugin": "^4.0.0-beta.8",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^24.9.0",
|
"jest": "^26.4.2",
|
||||||
"jest-pnp-resolver": "^1.2.1",
|
"jest-pnp-resolver": "^1.2.2",
|
||||||
"jest-resolve": "^24.9.0",
|
"jest-resolve": "^26.4.0",
|
||||||
"mini-css-extract-plugin": "^0.8.0",
|
"mini-css-extract-plugin": "^0.8.0",
|
||||||
"node-sass": "^4.12.0",
|
"node-sass": "^4.14.1",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"ocular.js": "^0.1.0",
|
"ocular.js": "^0.1.0",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||||
|
@ -120,20 +131,23 @@
|
||||||
"postcss-preset-env": "^6.7.0",
|
"postcss-preset-env": "^6.7.0",
|
||||||
"postcss-safe-parser": "^4.0.1",
|
"postcss-safe-parser": "^4.0.1",
|
||||||
"raf": "^3.4.1",
|
"raf": "^3.4.1",
|
||||||
"react-app-polyfill": "^1.0.4",
|
"react-app-polyfill": "^1.0.6",
|
||||||
"react-dev-utils": "^9.1.0",
|
"react-dev-utils": "^10.2.1",
|
||||||
"resolve": "^1.12.0",
|
"resolve": "^1.12.0",
|
||||||
"sass-loader": "^8.0.0",
|
"sass-loader": "^10.0.2",
|
||||||
"serve": "^11.2.0",
|
"serve": "^11.3.2",
|
||||||
"stryker-cli": "^1.0.0",
|
"stryker-cli": "^1.0.0",
|
||||||
"style-loader": "^1.0.0",
|
"style-loader": "^1.2.1",
|
||||||
"stylelint": "^9.10.1",
|
"stylelint": "^13.7.0",
|
||||||
"stylelint-config-adidas": "^1.2.1",
|
"stylelint-config-adidas": "^1.3.0",
|
||||||
"stylelint-config-adidas-bem": "^1.2.0",
|
"stylelint-config-adidas-bem": "^1.2.0",
|
||||||
"stylelint-config-recommended-scss": "^4.0.0",
|
"stylelint-config-recommended-scss": "^4.2.0",
|
||||||
"stylelint-scss": "^3.11.1",
|
"stylelint-scss": "^3.18.0",
|
||||||
"sw-precache-webpack-plugin": "^0.11.5",
|
"sw-precache-webpack-plugin": "^0.11.5",
|
||||||
"terser-webpack-plugin": "^2.1.2",
|
"terser-webpack-plugin": "^2.1.2",
|
||||||
|
"ts-jest": "^26.3.0",
|
||||||
|
"ts-mockery": "^1.2.0",
|
||||||
|
"typescript": "^3.9.7",
|
||||||
"url-loader": "^2.2.0",
|
"url-loader": "^2.2.0",
|
||||||
"webpack": "^4.41.0",
|
"webpack": "^4.41.0",
|
||||||
"webpack-dev-server": "^3.8.2",
|
"webpack-dev-server": "^3.8.2",
|
||||||
|
@ -144,6 +158,10 @@
|
||||||
"babel": {
|
"babel": {
|
||||||
"presets": [
|
"presets": [
|
||||||
"react-app"
|
"react-app"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/plugin-proposal-optional-chaining",
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||||
|
|
||||||
// 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';
|
||||||
|
@ -43,8 +43,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
||||||
// Process CLI arguments
|
// Process CLI arguments
|
||||||
const argvSliceStart = 2;
|
const argvSliceStart = 2;
|
||||||
const argv = process.argv.slice(argvSliceStart);
|
const argv = process.argv.slice(argvSliceStart);
|
||||||
const writeStatsJson = argv.indexOf('--stats') !== -1;
|
const writeStatsJson = argv.includes('--stats');
|
||||||
const withoutDist = argv.indexOf('--no-dist') !== -1;
|
const withoutDist = argv.includes('--no-dist');
|
||||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||||
|
|
||||||
// Generate configuration
|
// Generate configuration
|
||||||
|
@ -75,12 +75,12 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||||
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'));
|
||||||
|
@ -93,7 +93,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||||
previousFileSizes,
|
previousFileSizes,
|
||||||
paths.appBuild,
|
paths.appBuild,
|
||||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
||||||
WARN_AFTER_CHUNK_GZIP_SIZE
|
WARN_AFTER_CHUNK_GZIP_SIZE,
|
||||||
);
|
);
|
||||||
console.log();
|
console.log();
|
||||||
},
|
},
|
||||||
|
@ -101,7 +101,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||||
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);
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
@ -133,7 +133,7 @@ function build(previousFileSizes) {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
messages = formatWebpackMessages(
|
messages = formatWebpackMessages(
|
||||||
stats.toJson({ all: false, warnings: true, errors: true })
|
stats.toJson({ all: false, warnings: true, errors: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (messages.errors.length) {
|
if (messages.errors.length) {
|
||||||
|
@ -154,8 +154,8 @@ function build(previousFileSizes) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
'\nTreating warnings as errors because process.env.CI = true.\n' +
|
'\nTreating warnings as errors because process.env.CI = true.\n' +
|
||||||
'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')));
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||||
|
|
||||||
// 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';
|
||||||
|
@ -49,15 +49,15 @@ if (process.env.HOST) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.cyan(
|
chalk.cyan(
|
||||||
`Attempting to bind to HOST environment variable: ${chalk.yellow(
|
`Attempting to bind to HOST environment variable: ${chalk.yellow(
|
||||||
chalk.bold(process.env.HOST)
|
chalk.bold(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(
|
console.log(
|
||||||
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`
|
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`,
|
||||||
);
|
);
|
||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
@ -91,7 +91,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||||
// Serve webpack assets generated by the compiler over a web server.
|
// Serve webpack assets generated by the compiler over a web server.
|
||||||
const serverConfig = createDevServerConfig(
|
const serverConfig = createDevServerConfig(
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
urls.lanUrlForConfig
|
urls.lanUrlForConfig,
|
||||||
);
|
);
|
||||||
const devServer = new WebpackDevServer(compiler, serverConfig);
|
const devServer = new WebpackDevServer(compiler, serverConfig);
|
||||||
|
|
||||||
|
|
12
shlink-web-client.d.ts
vendored
Normal file
12
shlink-web-client.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
declare module 'event-source-polyfill' {
|
||||||
|
export const EventSourcePolyfill: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'csvjson' {
|
||||||
|
export declare class CsvJson {
|
||||||
|
public toObject<T>(content: string): T[];
|
||||||
|
public toCSV<T>(data: T[], options: { headers: string }): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png'
|
|
@ -1,15 +1,17 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, FC } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import NotFound from './common/NotFound';
|
import NotFound from './common/NotFound';
|
||||||
|
import { ServersMap } from './servers/data';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
const propTypes = {
|
interface AppProps {
|
||||||
fetchServers: PropTypes.func,
|
fetchServers: Function;
|
||||||
servers: PropTypes.object,
|
servers: ServersMap;
|
||||||
};
|
}
|
||||||
|
|
||||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer, Settings) => ({ fetchServers, servers }) => {
|
const App = (MainHeader: FC, Home: FC, MenuLayout: FC, CreateServer: FC, EditServer: FC, Settings: FC) => (
|
||||||
|
{ fetchServers, servers }: AppProps,
|
||||||
|
) => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(servers).length === 0) {
|
if (Object.keys(servers).length === 0) {
|
||||||
|
@ -35,6 +37,4 @@ const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer, Settings) =
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
App.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
|
@ -1,81 +0,0 @@
|
||||||
import {
|
|
||||||
faList as listIcon,
|
|
||||||
faLink as createIcon,
|
|
||||||
faTags as tagsIcon,
|
|
||||||
faPen as editIcon,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import React from 'react';
|
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import './AsideMenu.scss';
|
|
||||||
|
|
||||||
const AsideMenuItem = ({ children, to, className, ...rest }) => (
|
|
||||||
<NavLink
|
|
||||||
className={classNames('aside-menu__item', className)}
|
|
||||||
activeClassName="aside-menu__item--selected"
|
|
||||||
to={to}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
|
|
||||||
AsideMenuItem.propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
to: PropTypes.string.isRequired,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
selectedServer: serverType,
|
|
||||||
className: PropTypes.string,
|
|
||||||
showOnMobile: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AsideMenu = (DeleteServerButton) => {
|
|
||||||
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
|
||||||
const asideClass = classNames('aside-menu', className, {
|
|
||||||
'aside-menu--hidden': !showOnMobile,
|
|
||||||
});
|
|
||||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
|
||||||
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className={asideClass}>
|
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
|
||||||
<FontAwesomeIcon icon={listIcon} />
|
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
|
||||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
|
||||||
<span className="aside-menu__item-text">Create short URL</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
|
||||||
<span className="aside-menu__item-text">Edit this server</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<DeleteServerButton
|
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
|
||||||
textClassName="aside-menu__item-text"
|
|
||||||
server={selectedServer}
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AsideMenu.propTypes = propTypes;
|
|
||||||
|
|
||||||
return AsideMenu;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AsideMenu;
|
|
|
@ -18,7 +18,7 @@ $asideMenuMobileWidth: 280px;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
padding: 30px 15px 15px;
|
padding: 30px 15px 15px;
|
||||||
border-right: 1px solid #eee;
|
border-right: 1px solid #eeeeee;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
|
@ -51,17 +51,17 @@ $asideMenuMobileWidth: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--selected {
|
.aside-menu__item--selected {
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
background-color: $mainColor;
|
background-color: $mainColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--selected:hover {
|
.aside-menu__item--selected:hover {
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
background-color: $mainColor;
|
background-color: $mainColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--divider {
|
.aside-menu__item--divider {
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eeeeee;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ $asideMenuMobileWidth: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--danger:hover {
|
.aside-menu__item--danger:hover {
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
background-color: $dangerColor;
|
background-color: $dangerColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
77
src/common/AsideMenu.tsx
Normal file
77
src/common/AsideMenu.tsx
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import {
|
||||||
|
faList as listIcon,
|
||||||
|
faLink as createIcon,
|
||||||
|
faTags as tagsIcon,
|
||||||
|
faPen as editIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { NavLink, NavLinkProps } from 'react-router-dom';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Location } from 'history';
|
||||||
|
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||||
|
import { ServerWithId } from '../servers/data';
|
||||||
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
|
export interface AsideMenuProps {
|
||||||
|
selectedServer: ServerWithId;
|
||||||
|
className?: string;
|
||||||
|
showOnMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsideMenuItemProps extends NavLinkProps {
|
||||||
|
to: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||||
|
<NavLink
|
||||||
|
className={classNames('aside-menu__item', className)}
|
||||||
|
activeClassName="aside-menu__item--selected"
|
||||||
|
to={to}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
|
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
|
||||||
|
) => {
|
||||||
|
const serverId = selectedServer ? selectedServer.id : '';
|
||||||
|
const asideClass = classNames('aside-menu', className, {
|
||||||
|
'aside-menu--hidden': !showOnMobile,
|
||||||
|
});
|
||||||
|
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
||||||
|
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={asideClass}>
|
||||||
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
|
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||||
|
<FontAwesomeIcon icon={listIcon} />
|
||||||
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||||
|
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||||
|
<span className="aside-menu__item-text">Create short URL</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||||
|
<FontAwesomeIcon icon={tagsIcon} />
|
||||||
|
<span className="aside-menu__item-text">Manage tags</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||||
|
<FontAwesomeIcon icon={editIcon} />
|
||||||
|
<span className="aside-menu__item-text">Edit this server</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
<DeleteServerButton
|
||||||
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
|
textClassName="aside-menu__item-text"
|
||||||
|
server={selectedServer}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AsideMenu;
|
|
@ -1,30 +1,31 @@
|
||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import * as PropTypes from 'prop-types';
|
|
||||||
import './ErrorHandler.scss';
|
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
|
import './ErrorHandler.scss';
|
||||||
|
|
||||||
// FIXME Replace with typescript: (window, console)
|
interface ErrorHandlerState {
|
||||||
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
|
hasError: boolean;
|
||||||
static propTypes = {
|
}
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
const ErrorHandler = (
|
||||||
|
{ location }: Window,
|
||||||
|
{ error }: Console,
|
||||||
|
) => class ErrorHandler extends React.Component<any, ErrorHandlerState> {
|
||||||
|
public constructor(props: object) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError() {
|
public static getDerivedStateFromError(): ErrorHandlerState {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(e) {
|
public componentDidCatch(e: Error): void {
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
error(e);
|
error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
public render(): ReactNode | undefined {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="error-handler">
|
<div className="error-handler">
|
|
@ -1,16 +1,16 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './Home.scss';
|
|
||||||
import ServersListGroup from '../servers/ServersListGroup';
|
import ServersListGroup from '../servers/ServersListGroup';
|
||||||
|
import './Home.scss';
|
||||||
|
import { ServersMap } from '../servers/data';
|
||||||
|
|
||||||
const propTypes = {
|
export interface HomeProps {
|
||||||
resetSelectedServer: PropTypes.func,
|
resetSelectedServer: Function;
|
||||||
servers: PropTypes.object,
|
servers: ServersMap;
|
||||||
};
|
}
|
||||||
|
|
||||||
const Home = ({ resetSelectedServer, servers }) => {
|
const Home = ({ resetSelectedServer, servers }: HomeProps) => {
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const hasServers = !isEmpty(serversList);
|
const hasServers = !isEmpty(serversList);
|
||||||
|
|
||||||
|
@ -29,6 +29,4 @@ const Home = ({ resetSelectedServer, servers }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Home.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default Home;
|
export default Home;
|
|
@ -1,61 +0,0 @@
|
||||||
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import shlinkLogo from './shlink-logo-white.png';
|
|
||||||
import './MainHeader.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
location: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown) => {
|
|
||||||
const MainHeaderComp = ({ location }) => {
|
|
||||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
|
||||||
const { pathname } = location;
|
|
||||||
|
|
||||||
useEffect(close, [ location ]);
|
|
||||||
|
|
||||||
const createServerPath = '/server/create';
|
|
||||||
const settingsPath = '/settings';
|
|
||||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
|
||||||
<NavbarBrand tag={Link} to="/">
|
|
||||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
|
||||||
</NavbarBrand>
|
|
||||||
|
|
||||||
<NavbarToggler onClick={toggleOpen}>
|
|
||||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
|
||||||
</NavbarToggler>
|
|
||||||
|
|
||||||
<Collapse navbar isOpen={isOpen}>
|
|
||||||
<Nav navbar className="ml-auto">
|
|
||||||
<NavItem>
|
|
||||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
<NavItem>
|
|
||||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
<ServersDropdown />
|
|
||||||
</Nav>
|
|
||||||
</Collapse>
|
|
||||||
</Navbar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
MainHeaderComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return MainHeaderComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MainHeader;
|
|
51
src/common/MainHeader.tsx
Normal file
51
src/common/MainHeader.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import React, { FC, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { RouteComponentProps } from 'react-router';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import shlinkLogo from './shlink-logo-white.png';
|
||||||
|
import './MainHeader.scss';
|
||||||
|
|
||||||
|
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
||||||
|
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||||
|
const { pathname } = location;
|
||||||
|
|
||||||
|
useEffect(close, [ location ]);
|
||||||
|
|
||||||
|
const createServerPath = '/server/create';
|
||||||
|
const settingsPath = '/settings';
|
||||||
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
|
<NavbarBrand tag={Link} to="/">
|
||||||
|
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||||
|
</NavbarBrand>
|
||||||
|
|
||||||
|
<NavbarToggler onClick={toggleOpen}>
|
||||||
|
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||||
|
</NavbarToggler>
|
||||||
|
|
||||||
|
<Collapse navbar isOpen={isOpen}>
|
||||||
|
<Nav navbar className="ml-auto">
|
||||||
|
<NavItem>
|
||||||
|
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||||
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||||
|
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
<ServersDropdown />
|
||||||
|
</Nav>
|
||||||
|
</Collapse>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainHeader;
|
|
@ -1,98 +0,0 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { Route, Switch } from 'react-router-dom';
|
|
||||||
import { Swipeable } from 'react-swipeable';
|
|
||||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import * as PropTypes from 'prop-types';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import { versionMatch } from '../utils/helpers/version';
|
|
||||||
import NotFound from './NotFound';
|
|
||||||
import './MenuLayout.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
match: PropTypes.object,
|
|
||||||
location: PropTypes.object,
|
|
||||||
selectedServer: serverType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MenuLayout = (
|
|
||||||
TagsList,
|
|
||||||
ShortUrls,
|
|
||||||
AsideMenu,
|
|
||||||
CreateShortUrl,
|
|
||||||
ShortUrlVisits,
|
|
||||||
TagVisits,
|
|
||||||
ShlinkVersions,
|
|
||||||
ServerError
|
|
||||||
) => {
|
|
||||||
const MenuLayoutComp = ({ match, location, selectedServer }) => {
|
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
|
||||||
const { params: { serverId } } = match;
|
|
||||||
|
|
||||||
useEffect(() => hideSidebar(), [ location ]);
|
|
||||||
|
|
||||||
if (selectedServer.serverNotReachable) {
|
|
||||||
return <ServerError type="not-reachable" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
|
||||||
'menu-layout__burger-icon--active': sidebarVisible,
|
|
||||||
});
|
|
||||||
const swipeMenuIfNoModalExists = (callback) => (e) => {
|
|
||||||
const swippedOnVisitsTable = e.event.path.some(
|
|
||||||
({ classList }) => classList && classList.contains('visits-table')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
|
||||||
|
|
||||||
<Swipeable
|
|
||||||
delta={40}
|
|
||||||
className="menu-layout__swipeable"
|
|
||||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
|
||||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
|
||||||
>
|
|
||||||
<div className="row menu-layout__swipeable-inner">
|
|
||||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
|
||||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
|
||||||
<div className="menu-layout__container">
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
|
||||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
|
||||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
|
||||||
<Route
|
|
||||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="menu-layout__footer text-center text-md-right">
|
|
||||||
<ShlinkVersions />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Swipeable>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
MenuLayoutComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return withSelectedServer(MenuLayoutComp, ServerError);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuLayout;
|
|
85
src/common/MenuLayout.tsx
Normal file
85
src/common/MenuLayout.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import React, { FC, useEffect } from 'react';
|
||||||
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
import { EventData, Swipeable } from 'react-swipeable';
|
||||||
|
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { versionMatch } from '../utils/helpers/version';
|
||||||
|
import { isReachableServer } from '../servers/data';
|
||||||
|
import NotFound from './NotFound';
|
||||||
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
|
const MenuLayout = (
|
||||||
|
TagsList: FC,
|
||||||
|
ShortUrls: FC,
|
||||||
|
AsideMenu: FC<AsideMenuProps>,
|
||||||
|
CreateShortUrl: FC,
|
||||||
|
ShortUrlVisits: FC,
|
||||||
|
TagVisits: FC,
|
||||||
|
ShlinkVersions: FC,
|
||||||
|
ServerError: FC,
|
||||||
|
) => withSelectedServer(({ location, selectedServer }) => {
|
||||||
|
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||||
|
|
||||||
|
useEffect(() => hideSidebar(), [ location ]);
|
||||||
|
|
||||||
|
if (!isReachableServer(selectedServer)) {
|
||||||
|
return <ServerError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
||||||
|
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||||
|
'menu-layout__burger-icon--active': sidebarVisible,
|
||||||
|
});
|
||||||
|
const swipeMenuIfNoModalExists = (callback: () => void) => (e: EventData) => {
|
||||||
|
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
||||||
|
({ classList }) => classList?.contains('visits-table'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||||
|
|
||||||
|
<Swipeable
|
||||||
|
delta={40}
|
||||||
|
className="menu-layout__swipeable"
|
||||||
|
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
||||||
|
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
||||||
|
>
|
||||||
|
<div className="row menu-layout__swipeable-inner">
|
||||||
|
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||||
|
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||||
|
<div className="menu-layout__container">
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||||
|
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||||
|
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
|
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||||
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
|
<Route
|
||||||
|
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="menu-layout__footer text-center text-md-right">
|
||||||
|
<ShlinkVersions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Swipeable>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}, ServerError);
|
||||||
|
|
||||||
|
export default MenuLayout;
|
|
@ -1,13 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './NoMenuLayout.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
const NoMenuLayout = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
|
||||||
|
|
||||||
NoMenuLayout.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default NoMenuLayout;
|
|
6
src/common/NoMenuLayout.tsx
Normal file
6
src/common/NoMenuLayout.tsx
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
|
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
||||||
|
|
||||||
|
export default NoMenuLayout;
|
|
@ -1,13 +1,11 @@
|
||||||
import React from 'react';
|
import React, { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import * as PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
interface NotFoundProps {
|
||||||
to: PropTypes.string,
|
to?: string;
|
||||||
children: PropTypes.node,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const NotFound = ({ to = '/', children = 'Home' }) => (
|
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
<h2>Oops! We could not find requested route.</h2>
|
||||||
<p>
|
<p>
|
||||||
|
@ -19,6 +17,4 @@ const NotFound = ({ to = '/', children = 'Home' }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
NotFound.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default NotFound;
|
export default NotFound;
|
|
@ -1,23 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
location: PropTypes.object,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ScrollToTop = () => {
|
|
||||||
const ScrollToTopComp = ({ location, children }) => {
|
|
||||||
useEffect(() => {
|
|
||||||
scrollTo(0, 0);
|
|
||||||
}, [ location ]);
|
|
||||||
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
ScrollToTopComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return ScrollToTopComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ScrollToTop;
|
|
12
src/common/ScrollToTop.tsx
Normal file
12
src/common/ScrollToTop.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React, { PropsWithChildren, useEffect } from 'react';
|
||||||
|
import { RouteComponentProps } from 'react-router';
|
||||||
|
|
||||||
|
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
||||||
|
useEffect(() => {
|
||||||
|
scrollTo(0, 0);
|
||||||
|
}, [ location ]);
|
||||||
|
|
||||||
|
return <React.Fragment>{children}</React.Fragment>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollToTop;
|
|
@ -1,45 +1,43 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||||
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
|
|
||||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||||
|
|
||||||
const propTypes = {
|
export interface ShlinkVersionsProps {
|
||||||
selectedServer: serverType,
|
selectedServer: SelectedServer;
|
||||||
className: PropTypes.string,
|
clientVersion?: string;
|
||||||
clientVersion: PropTypes.string,
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
const versionLinkPropTypes = {
|
interface VersionLinkProps {
|
||||||
project: PropTypes.oneOf([ 'shlink', 'shlink-web-client' ]).isRequired,
|
project: 'shlink' | 'shlink-web-client';
|
||||||
version: PropTypes.string.isRequired,
|
version: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
const VersionLink = ({ project, version }) => (
|
const VersionLink = ({ project, version }: VersionLinkProps) => (
|
||||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
||||||
<b>{version}</b>
|
<b>{version}</b>
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
VersionLink.propTypes = versionLinkPropTypes;
|
const ShlinkVersions = (
|
||||||
|
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
|
||||||
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
|
) => {
|
||||||
const { printableVersion: serverVersion } = selectedServer;
|
|
||||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<small className={classNames('text-muted', className)}>
|
<small className={classNames('text-muted', className)}>
|
||||||
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} /> -
|
{isReachableServer(selectedServer) &&
|
||||||
Server: <VersionLink project="shlink" version={serverVersion} />
|
<React.Fragment>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </React.Fragment>
|
||||||
|
}
|
||||||
|
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||||
</small>
|
</small>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ShlinkVersions.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ShlinkVersions;
|
export default ShlinkVersions;
|
|
@ -1,23 +1,22 @@
|
||||||
import React from 'react';
|
import React, { FC } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
import { pageIsEllipsis, keyForPage, NumberOrEllipsis, progressivePagination } from '../utils/helpers/pagination';
|
||||||
import './SimplePaginator.scss';
|
import './SimplePaginator.scss';
|
||||||
|
|
||||||
const propTypes = {
|
interface SimplePaginatorProps {
|
||||||
pagesCount: PropTypes.number.isRequired,
|
pagesCount: number;
|
||||||
currentPage: PropTypes.number.isRequired,
|
currentPage: number;
|
||||||
setCurrentPage: PropTypes.func.isRequired,
|
setCurrentPage: (currentPage: number) => void;
|
||||||
centered: PropTypes.bool,
|
centered?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||||
if (pagesCount < 2) {
|
if (pagesCount < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (page) => () => setCurrentPage(page);
|
const onClick = (page: NumberOrEllipsis) => () => !pageIsEllipsis(page) && setCurrentPage(page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
||||||
|
@ -27,7 +26,7 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = t
|
||||||
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={keyForPage(pageNumber, index)}
|
key={keyForPage(pageNumber, index)}
|
||||||
disabled={isPageDisabled(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
||||||
|
@ -40,6 +39,4 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = t
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SimplePaginator.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default SimplePaginator;
|
export default SimplePaginator;
|
|
@ -1,6 +1,6 @@
|
||||||
.react-tagsinput {
|
.react-tagsinput {
|
||||||
background-color: #fff;
|
background-color: #ffffff;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #cccccc;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 2.6rem;
|
min-height: 2.6rem;
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
margin: 0 5px 6px 0;
|
margin: 0 5px 6px 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tagsinput-remove {
|
.react-tagsinput-remove {
|
||||||
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
.react-tagsinput-tag span:before {
|
.react-tagsinput-tag span:before {
|
||||||
content: '\2715';
|
content: '\2715';
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tagsinput-input {
|
.react-tagsinput-input {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import ScrollToTop from '../ScrollToTop';
|
import ScrollToTop from '../ScrollToTop';
|
||||||
import MainHeader from '../MainHeader';
|
import MainHeader from '../MainHeader';
|
||||||
import Home from '../Home';
|
import Home from '../Home';
|
||||||
|
@ -5,12 +6,13 @@ import MenuLayout from '../MenuLayout';
|
||||||
import AsideMenu from '../AsideMenu';
|
import AsideMenu from '../AsideMenu';
|
||||||
import ErrorHandler from '../ErrorHandler';
|
import ErrorHandler from '../ErrorHandler';
|
||||||
import ShlinkVersions from '../ShlinkVersions';
|
import ShlinkVersions from '../ShlinkVersions';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
const provideServices = (bottle, connect, withRouter) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
bottle.constant('window', global.window);
|
bottle.constant('window', (global as any).window);
|
||||||
bottle.constant('console', global.console);
|
bottle.constant('console', global.console);
|
||||||
|
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
|
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||||
bottle.decorator('ScrollToTop', withRouter);
|
bottle.decorator('ScrollToTop', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||||
|
@ -29,7 +31,7 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
'TagVisits',
|
'TagVisits',
|
||||||
'ShlinkVersions',
|
'ShlinkVersions',
|
||||||
'ServerError'
|
'ServerError',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
|
@ -1,4 +1,4 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle, { IContainer } from 'bottlejs';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { connect as reduxConnect } from 'react-redux';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
|
@ -11,21 +11,23 @@ import provideTagsServices from '../tags/services/provideServices';
|
||||||
import provideUtilsServices from '../utils/services/provideServices';
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
import provideMercureServices from '../mercure/services/provideServices';
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
import provideSettingsServices from '../settings/services/provideServices';
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
|
import { ConnectDecorator } from './types';
|
||||||
|
|
||||||
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
const { container } = bottle;
|
const { container } = bottle;
|
||||||
|
|
||||||
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
|
const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => container[serviceName](...args);
|
||||||
const mapActionService = (map, actionName) => ({
|
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||||
...map,
|
...map,
|
||||||
|
|
||||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
[actionName]: lazyService(container, actionName),
|
[actionName]: lazyService(container, actionName),
|
||||||
});
|
});
|
||||||
const connect = (propsFromState, actionServiceNames = []) =>
|
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||||
reduxConnect(
|
reduxConnect(
|
||||||
propsFromState ? pick(propsFromState) : null,
|
propsFromState ? pick(propsFromState) : null,
|
||||||
actionServiceNames.reduce(mapActionService, {})
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings');
|
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings');
|
|
@ -1,13 +1,12 @@
|
||||||
import ReduxThunk from 'redux-thunk';
|
import ReduxThunk from 'redux-thunk';
|
||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
import { applyMiddleware, compose, createStore } from 'redux';
|
||||||
import { save, load } from 'redux-localstorage-simple';
|
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||||
import reducers from '../reducers';
|
import reducers from '../reducers';
|
||||||
|
|
||||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
const isProduction = process.env.NODE_ENV !== 'production';
|
||||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
: compose;
|
|
||||||
|
|
||||||
const localStorageConfig = {
|
const localStorageConfig: RLSOptions = {
|
||||||
states: [ 'settings', 'servers' ],
|
states: [ 'settings', 'servers' ],
|
||||||
namespace: 'shlink',
|
namespace: 'shlink',
|
||||||
namespaceSeparator: '.',
|
namespaceSeparator: '.',
|
||||||
|
@ -15,7 +14,7 @@ const localStorageConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
||||||
applyMiddleware(save(localStorageConfig), ReduxThunk)
|
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||||
));
|
));
|
||||||
|
|
||||||
export default store;
|
export default store;
|
40
src/container/types.ts
Normal file
40
src/container/types.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { MercureInfo } from '../mercure/reducers/mercureInfo';
|
||||||
|
import { SelectedServer, ServersMap } from '../servers/data';
|
||||||
|
import { Settings } from '../settings/reducers/settings';
|
||||||
|
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
|
||||||
|
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||||
|
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||||
|
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||||
|
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
||||||
|
import { ShortUrlTags } from '../short-urls/reducers/shortUrlTags';
|
||||||
|
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||||
|
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||||
|
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||||
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
|
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
||||||
|
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
|
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||||
|
|
||||||
|
export interface ShlinkState {
|
||||||
|
servers: ServersMap;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
shortUrlsList: ShortUrlsList;
|
||||||
|
shortUrlsListParams: ShortUrlsListParams;
|
||||||
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
|
shortUrlTags: ShortUrlTags;
|
||||||
|
shortUrlMeta: ShortUrlMetaEdition;
|
||||||
|
shortUrlEdition: ShortUrlEdition;
|
||||||
|
shortUrlVisits: ShortUrlVisits;
|
||||||
|
tagVisits: TagVisits;
|
||||||
|
shortUrlDetail: ShortUrlDetail;
|
||||||
|
tagsList: TagsList;
|
||||||
|
tagDelete: TagDeletion;
|
||||||
|
tagEdit: TagEdition;
|
||||||
|
mercureInfo: MercureInfo;
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|
||||||
|
export type GetState = () => ShlinkState;
|
|
@ -25,7 +25,7 @@ body,
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-main {
|
.badge-main {
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
background-color: $mainColor;
|
background-color: $mainColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from 'react-dom';
|
import { render } from 'react-dom';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
@ -7,7 +6,8 @@ import { homepage } from '../package.json';
|
||||||
import registerServiceWorker from './registerServiceWorker';
|
import registerServiceWorker from './registerServiceWorker';
|
||||||
import container from './container';
|
import container from './container';
|
||||||
import store from './container/store';
|
import store from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/utils';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './common/react-tagsinput.scss';
|
import './common/react-tagsinput.scss';
|
||||||
|
@ -28,6 +28,6 @@ render(
|
||||||
</ErrorHandler>
|
</ErrorHandler>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>,
|
</Provider>,
|
||||||
document.getElementById('root')
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
|
@ -1,28 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
|
||||||
|
|
||||||
export const bindToMercureTopic = (mercureInfo, topic, onMessage, onTokenExpired) => () => {
|
|
||||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
|
||||||
|
|
||||||
if (loading || error) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hubUrl = new URL(mercureHubUrl);
|
|
||||||
|
|
||||||
hubUrl.searchParams.append('topic', topic);
|
|
||||||
const es = new EventSource(hubUrl, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
es.onmessage = ({ data }) => onMessage(JSON.parse(data));
|
|
||||||
es.onerror = ({ status }) => status === 401 && onTokenExpired();
|
|
||||||
|
|
||||||
return () => es.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMercureTopicBinding = (mercureInfo, topic, onMessage, onTokenExpired) => {
|
|
||||||
useEffect(bindToMercureTopic(mercureInfo, topic, onMessage, onTokenExpired), [ mercureInfo ]);
|
|
||||||
};
|
|
40
src/mercure/helpers/index.ts
Normal file
40
src/mercure/helpers/index.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||||
|
import { MercureInfo } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
|
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => () => { // eslint-disable-line max-len
|
||||||
|
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||||
|
|
||||||
|
if (loading || error || !mercureHubUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubUrl = new URL(mercureHubUrl);
|
||||||
|
|
||||||
|
hubUrl.searchParams.append('topic', topic);
|
||||||
|
const es = new EventSource(hubUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onmessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
||||||
|
es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
||||||
|
|
||||||
|
return () => es.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMercureTopicBinding = <T>(
|
||||||
|
mercureInfo: MercureInfo,
|
||||||
|
topic: string,
|
||||||
|
onMessage: (message: T) => void,
|
||||||
|
onTokenExpired: Function,
|
||||||
|
) => {
|
||||||
|
useEffect(bindToMercureTopic(mercureInfo, topic, onMessage, onTokenExpired), [ mercureInfo ]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MercureBoundProps {
|
||||||
|
createNewVisit: (message: any) => void;
|
||||||
|
loadMercureInfo: Function;
|
||||||
|
mercureInfo: MercureInfo;
|
||||||
|
}
|
|
@ -1,49 +0,0 @@
|
||||||
import { handleActions } from 'redux-actions';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
|
||||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
|
||||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
export const MercureInfoType = PropTypes.shape({
|
|
||||||
token: PropTypes.string,
|
|
||||||
mercureHubUrl: PropTypes.string,
|
|
||||||
loading: PropTypes.bool,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
token: undefined,
|
|
||||||
mercureHubUrl: undefined,
|
|
||||||
loading: true,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default handleActions({
|
|
||||||
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
|
||||||
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
|
||||||
[GET_MERCURE_INFO]: (state, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }),
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const loadMercureInfo = (buildShlinkApiClient) => () => async (dispatch, getState) => {
|
|
||||||
dispatch({ type: GET_MERCURE_INFO_START });
|
|
||||||
|
|
||||||
const { settings } = getState();
|
|
||||||
const { mercureInfo } = buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
if (!settings.realTimeUpdates.enabled) {
|
|
||||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await mercureInfo();
|
|
||||||
|
|
||||||
dispatch({ type: GET_MERCURE_INFO, ...result });
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
|
||||||
}
|
|
||||||
};
|
|
53
src/mercure/reducers/mercureInfo.ts
Normal file
53
src/mercure/reducers/mercureInfo.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
import { ShlinkMercureInfo } from '../../utils/services/types';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||||
|
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||||
|
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export interface MercureInfo {
|
||||||
|
token?: string;
|
||||||
|
mercureHubUrl?: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetMercureInfoAction = Action<string> & ShlinkMercureInfo;
|
||||||
|
|
||||||
|
const initialState: MercureInfo = {
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildReducer<MercureInfo, GetMercureInfoAction>({
|
||||||
|
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
|
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||||
|
[GET_MERCURE_INFO]: (_, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
|
() => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
|
dispatch({ type: GET_MERCURE_INFO_START });
|
||||||
|
|
||||||
|
const { settings } = getState();
|
||||||
|
const { mercureInfo } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
if (!settings.realTimeUpdates.enabled) {
|
||||||
|
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await mercureInfo();
|
||||||
|
|
||||||
|
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, ...result });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,6 +1,7 @@
|
||||||
|
import Bottle from 'bottlejs';
|
||||||
import { loadMercureInfo } from '../reducers/mercureInfo';
|
import { loadMercureInfo } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
const provideServices = (bottle) => {
|
const provideServices = (bottle: Bottle) => {
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
||||||
};
|
};
|
|
@ -16,8 +16,9 @@ import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||||
import settingsReducer from '../settings/reducers/settings';
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers<ShlinkState>({
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: selectedServerReducer,
|
selectedServer: selectedServerReducer,
|
||||||
shortUrlsList: shortUrlsListReducer,
|
shortUrlsList: shortUrlsListReducer,
|
|
@ -1,3 +1,5 @@
|
||||||
|
/* eslint-disable @typescript-eslint/promise-function-async, @typescript-eslint/no-misused-promises */
|
||||||
|
|
||||||
// In production, we register a service worker to serve assets from local cache.
|
// In production, we register a service worker to serve assets from local cache.
|
||||||
|
|
||||||
// This lets the app load faster on subsequent visits in production, and gives
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
@ -18,8 +20,8 @@ const isLocalhost = Boolean(
|
||||||
|
|
||||||
// 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}$/,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function register() {
|
export default function register() {
|
||||||
|
@ -46,7 +48,7 @@ export default function register() {
|
||||||
return navigator.serviceWorker.ready.then(() => {
|
return navigator.serviceWorker.ready.then(() => {
|
||||||
console.log(
|
console.log(
|
||||||
'This web app is being served cache-first by a service ' +
|
'This web app is being served cache-first by a service ' +
|
||||||
'worker. To learn more, visit https://goo.gl/SC7cgQ'
|
'worker. To learn more, visit https://goo.gl/SC7cgQ',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -96,7 +98,7 @@ function checkValidServiceWorker(swUrl) {
|
||||||
// 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 === NOT_FOUND_STATUS ||
|
response.status === NOT_FOUND_STATUS ||
|
||||||
response.headers.get('content-type').indexOf('javascript') === -1
|
response.headers.get('content-type').includes('javascript')
|
||||||
) {
|
) {
|
||||||
// No service worker found. Probably a different app. Reload the page.
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
return navigator.serviceWorker.ready.then((registration) =>
|
return navigator.serviceWorker.ready.then((registration) =>
|
||||||
|
@ -110,7 +112,7 @@ function checkValidServiceWorker(swUrl) {
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.log(
|
console.log(
|
||||||
'No internet connection found. App is running in offline mode.'
|
'No internet connection found. App is running in offline mode.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
|
||||||
import './CreateServer.scss';
|
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
|
||||||
const propTypes = {
|
|
||||||
createServer: PropTypes.func,
|
|
||||||
history: PropTypes.shape({
|
|
||||||
push: PropTypes.func,
|
|
||||||
}),
|
|
||||||
resetSelectedServer: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
|
||||||
const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
|
|
||||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
|
||||||
const handleSubmit = (serverData) => {
|
|
||||||
const id = uuid();
|
|
||||||
const server = { id, ...serverData };
|
|
||||||
|
|
||||||
createServer(server);
|
|
||||||
push(`/server/${id}/list-short-urls/1`);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetSelectedServer();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NoMenuLayout>
|
|
||||||
<ServerForm onSubmit={handleSubmit}>
|
|
||||||
<ImportServersBtn onImport={setServersImported} />
|
|
||||||
<button className="btn btn-outline-primary">Create server</button>
|
|
||||||
</ServerForm>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</NoMenuLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CreateServerComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return CreateServerComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateServer;
|
|
63
src/servers/CreateServer.tsx
Normal file
63
src/servers/CreateServer.tsx
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import React, { FC, useEffect } from 'react';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { RouterProps } from 'react-router';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||||
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
|
import { ServerData, ServerWithId } from './data';
|
||||||
|
import './CreateServer.scss';
|
||||||
|
|
||||||
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
|
interface CreateServerProps extends RouterProps {
|
||||||
|
createServer: (server: ServerWithId) => void;
|
||||||
|
resetSelectedServer: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-10 offset-md-1">
|
||||||
|
<div
|
||||||
|
className={classNames('p-2 mt-3 text-white text-center', {
|
||||||
|
'bg-main': type === 'success',
|
||||||
|
'bg-danger': type === 'error',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
|
{ createServer, history: { push }, resetSelectedServer }: CreateServerProps,
|
||||||
|
) => {
|
||||||
|
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
|
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
|
const id = uuid();
|
||||||
|
|
||||||
|
createServer({ ...serverData, id });
|
||||||
|
push(`/server/${id}/list-short-urls/1`);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetSelectedServer();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoMenuLayout>
|
||||||
|
<ServerForm onSubmit={handleSubmit}>
|
||||||
|
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
||||||
|
<button className="btn btn-outline-primary">Create server</button>
|
||||||
|
</ServerForm>
|
||||||
|
|
||||||
|
{serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
|
||||||
|
{errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
|
||||||
|
</NoMenuLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateServer;
|
|
@ -1,36 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import { serverType } from './prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
server: serverType,
|
|
||||||
className: PropTypes.string,
|
|
||||||
textClassName: PropTypes.string,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeleteServerButton = (DeleteServerModal) => {
|
|
||||||
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
|
|
||||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<span className={className} onClick={showModal}>
|
|
||||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
|
||||||
<span className={textClassName}>{children || 'Remove this server'}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
DeleteServerButtonComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return DeleteServerButtonComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DeleteServerButton;
|
|
31
src/servers/DeleteServerButton.tsx
Normal file
31
src/servers/DeleteServerButton.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
|
export interface DeleteServerButtonProps {
|
||||||
|
server: ServerWithId;
|
||||||
|
className?: string;
|
||||||
|
textClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
|
||||||
|
{ server, className, children, textClassName },
|
||||||
|
) => {
|
||||||
|
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<span className={className} onClick={showModal}>
|
||||||
|
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||||
|
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteServerButton;
|
|
@ -1,19 +1,19 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { serverType } from './prop-types';
|
import { RouterProps } from 'react-router';
|
||||||
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
const propTypes = {
|
export interface DeleteServerModalProps {
|
||||||
toggle: PropTypes.func.isRequired,
|
server: ServerWithId;
|
||||||
isOpen: PropTypes.bool.isRequired,
|
toggle: () => void;
|
||||||
server: serverType,
|
isOpen: boolean;
|
||||||
deleteServer: PropTypes.func,
|
}
|
||||||
history: PropTypes.shape({
|
|
||||||
push: PropTypes.func,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
|
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||||
|
deleteServer: (server: ServerWithId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
deleteServer(server);
|
deleteServer(server);
|
||||||
toggle();
|
toggle();
|
||||||
|
@ -40,6 +40,4 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
DeleteServerModal.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default DeleteServerModal;
|
export default DeleteServerModal;
|
|
@ -1,38 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Button } from 'reactstrap';
|
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
|
||||||
import { serverType } from './prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
editServer: PropTypes.func,
|
|
||||||
selectedServer: serverType,
|
|
||||||
history: PropTypes.shape({
|
|
||||||
push: PropTypes.func,
|
|
||||||
goBack: PropTypes.func,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EditServer = (ServerError) => {
|
|
||||||
const EditServerComp = ({ editServer, selectedServer, history: { push, goBack } }) => {
|
|
||||||
const handleSubmit = (serverData) => {
|
|
||||||
editServer(selectedServer.id, serverData);
|
|
||||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NoMenuLayout>
|
|
||||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
|
||||||
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
|
||||||
<Button outline color="primary">Save</Button>
|
|
||||||
</ServerForm>
|
|
||||||
</NoMenuLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
EditServerComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return withSelectedServer(EditServerComp, ServerError);
|
|
||||||
};
|
|
32
src/servers/EditServer.tsx
Normal file
32
src/servers/EditServer.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
|
import { isServerWithId, ServerData } from './data';
|
||||||
|
|
||||||
|
interface EditServerProps {
|
||||||
|
editServer: (serverId: string, serverData: ServerData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||||
|
{ editServer, selectedServer, history: { push, goBack } },
|
||||||
|
) => {
|
||||||
|
if (!isServerWithId(selectedServer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
|
editServer(selectedServer.id, serverData);
|
||||||
|
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoMenuLayout>
|
||||||
|
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||||
|
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||||
|
<Button outline color="primary">Save</Button>
|
||||||
|
</ServerForm>
|
||||||
|
</NoMenuLayout>
|
||||||
|
);
|
||||||
|
}, ServerError);
|
|
@ -1,55 +0,0 @@
|
||||||
import { isEmpty, values } from 'ramda';
|
|
||||||
import React from 'react';
|
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { serverType } from './prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
servers: PropTypes.object,
|
|
||||||
selectedServer: serverType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ServersDropdown = (serversExporter) => {
|
|
||||||
const ServersDropdownComp = ({ servers, selectedServer }) => {
|
|
||||||
const serversList = values(servers);
|
|
||||||
|
|
||||||
const renderServers = () => {
|
|
||||||
if (isEmpty(serversList)) {
|
|
||||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
{serversList.map(({ name, id }) => (
|
|
||||||
<DropdownItem
|
|
||||||
key={id}
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${id}/list-short-urls/1`}
|
|
||||||
active={selectedServer && selectedServer.id === id}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</DropdownItem>
|
|
||||||
))}
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
|
|
||||||
Export servers
|
|
||||||
</DropdownItem>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UncontrolledDropdown nav inNavbar>
|
|
||||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
|
||||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
|
||||||
</UncontrolledDropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ServersDropdownComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return ServersDropdownComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ServersDropdown;
|
|
49
src/servers/ServersDropdown.tsx
Normal file
49
src/servers/ServersDropdown.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { isEmpty, values } from 'ramda';
|
||||||
|
import React from 'react';
|
||||||
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import ServersExporter from './services/ServersExporter';
|
||||||
|
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
||||||
|
|
||||||
|
export interface ServersDropdownProps {
|
||||||
|
servers: ServersMap;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||||
|
const serversList = values(servers);
|
||||||
|
|
||||||
|
const renderServers = () => {
|
||||||
|
if (isEmpty(serversList)) {
|
||||||
|
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{serversList.map(({ name, id }) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={id}
|
||||||
|
tag={Link}
|
||||||
|
to={`/server/${id}/list-short-urls/1`}
|
||||||
|
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
||||||
|
Export servers
|
||||||
|
</DropdownItem>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UncontrolledDropdown nav inNavbar>
|
||||||
|
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||||
|
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||||
|
</UncontrolledDropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServersDropdown;
|
|
@ -1,30 +1,23 @@
|
||||||
import React from 'react';
|
import React, { FC } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { serverType } from './prop-types';
|
|
||||||
import './ServersListGroup.scss';
|
import './ServersListGroup.scss';
|
||||||
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
const propTypes = {
|
interface ServersListGroup {
|
||||||
servers: PropTypes.arrayOf(serverType).isRequired,
|
servers: ServerWithId[];
|
||||||
children: PropTypes.node.isRequired,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const ServerListItem = ({ id, name }) => (
|
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||||
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
|
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
|
||||||
{name}
|
{name}
|
||||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||||
</ListGroupItem>
|
</ListGroupItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
ServerListItem.propTypes = {
|
const ServersListGroup: FC<ServersListGroup> = ({ servers, children }) => (
|
||||||
id: PropTypes.string,
|
|
||||||
name: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ServersListGroup = ({ servers, children }) => (
|
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h5>{children}</h5>
|
<h5>{children}</h5>
|
||||||
|
@ -37,6 +30,4 @@ const ServersListGroup = ({ servers, children }) => (
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
||||||
ServersListGroup.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ServersListGroup;
|
export default ServersListGroup;
|
40
src/servers/data/index.ts
Normal file
40
src/servers/data/index.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
export interface ServerData {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerWithId extends ServerData {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReachableServer extends ServerWithId {
|
||||||
|
version: string;
|
||||||
|
printableVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NonReachableServer extends ServerWithId {
|
||||||
|
serverNotReachable: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotFoundServer {
|
||||||
|
serverNotFound: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegularServer = ReachableServer | NonReachableServer;
|
||||||
|
|
||||||
|
export type SelectedServer = RegularServer | NotFoundServer | null;
|
||||||
|
|
||||||
|
export type ServersMap = Record<string, ServerWithId>;
|
||||||
|
|
||||||
|
export const hasServerData = (server: SelectedServer | ServerData): server is ServerData =>
|
||||||
|
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
|
||||||
|
|
||||||
|
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
|
||||||
|
!!server?.hasOwnProperty('id');
|
||||||
|
|
||||||
|
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
|
||||||
|
!!server?.hasOwnProperty('printableVersion');
|
||||||
|
|
||||||
|
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||||
|
!!server?.hasOwnProperty('serverNotFound');
|
|
@ -1,30 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { serverType } from '../prop-types';
|
|
||||||
import { versionMatch } from '../../utils/helpers/version';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
minVersion: PropTypes.string,
|
|
||||||
maxVersion: PropTypes.string,
|
|
||||||
selectedServer: serverType,
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children }) => {
|
|
||||||
if (!selectedServer) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { version } = selectedServer;
|
|
||||||
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
|
|
||||||
|
|
||||||
if (!matchesVersion) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <React.Fragment>{children}</React.Fragment>;
|
|
||||||
};
|
|
||||||
|
|
||||||
ForServerVersion.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ForServerVersion;
|
|
24
src/servers/helpers/ForServerVersion.tsx
Normal file
24
src/servers/helpers/ForServerVersion.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { versionMatch, Versions } from '../../utils/helpers/version';
|
||||||
|
import { isReachableServer, SelectedServer } from '../data';
|
||||||
|
|
||||||
|
interface ForServerVersionProps extends Versions {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ForServerVersion: FC<ForServerVersionProps> = ({ minVersion, maxVersion, selectedServer, children }) => {
|
||||||
|
if (!isReachableServer(selectedServer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version } = selectedServer;
|
||||||
|
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
|
||||||
|
|
||||||
|
if (!matchesVersion) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <React.Fragment>{children}</React.Fragment>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForServerVersion;
|
|
@ -1,48 +0,0 @@
|
||||||
import React, { useRef } from 'react';
|
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
onImport: PropTypes.func,
|
|
||||||
createServers: PropTypes.func,
|
|
||||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
|
||||||
};
|
|
||||||
|
|
||||||
// FIXME Replace with typescript: (ServersImporter)
|
|
||||||
const ImportServersBtn = ({ importServersFromFile }) => {
|
|
||||||
const ImportServersBtnComp = ({ createServers, fileRef, onImport = () => {} }) => {
|
|
||||||
const ref = fileRef || useRef();
|
|
||||||
const onChange = ({ target }) =>
|
|
||||||
importServersFromFile(target.files[0])
|
|
||||||
.then(createServers)
|
|
||||||
.then(onImport)
|
|
||||||
.then(() => {
|
|
||||||
// Reset input after processing file
|
|
||||||
target.value = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline-secondary mr-2"
|
|
||||||
id="importBtn"
|
|
||||||
onClick={() => ref.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={ref} onChange={onChange} />
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ImportServersBtnComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return ImportServersBtnComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImportServersBtn;
|
|
54
src/servers/helpers/ImportServersBtn.tsx
Normal file
54
src/servers/helpers/ImportServersBtn.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import React, { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import ServersImporter from '../services/ServersImporter';
|
||||||
|
import { ServerData } from '../data';
|
||||||
|
|
||||||
|
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||||
|
|
||||||
|
export interface ImportServersBtnProps {
|
||||||
|
onImport?: () => void;
|
||||||
|
onImportError?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
|
createServers: (servers: ServerData[]) => void;
|
||||||
|
fileRef: Ref<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
||||||
|
createServers,
|
||||||
|
fileRef,
|
||||||
|
onImport = () => {},
|
||||||
|
onImportError = () => {},
|
||||||
|
}: ImportServersBtnConnectProps) => {
|
||||||
|
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||||
|
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
importServersFromFile(target.files?.[0])
|
||||||
|
.then(createServers)
|
||||||
|
.then(onImport)
|
||||||
|
.then(() => {
|
||||||
|
// Reset input after processing file
|
||||||
|
(target as { value: string | null }).value = null;
|
||||||
|
})
|
||||||
|
.catch(onImportError);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary mr-2"
|
||||||
|
id="importBtn"
|
||||||
|
onClick={() => ref.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={ref} onChange={onChange} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportServersBtn;
|
|
@ -1,50 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Message from '../../utils/Message';
|
|
||||||
import ServersListGroup from '../ServersListGroup';
|
|
||||||
import { serverType } from '../prop-types';
|
|
||||||
import './ServerError.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
servers: PropTypes.object,
|
|
||||||
selectedServer: serverType,
|
|
||||||
type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ServerError = (DeleteServerButton) => {
|
|
||||||
const ServerErrorComp = ({ type, servers, selectedServer }) => (
|
|
||||||
<div className="server-error__container flex-column">
|
|
||||||
<div className="row w-100 mb-3 mb-md-5">
|
|
||||||
<Message type="error">
|
|
||||||
{type === 'not-found' && 'Could not find this Shlink server.'}
|
|
||||||
{type === 'not-reachable' && (
|
|
||||||
<React.Fragment>
|
|
||||||
<p>Oops! Could not connect to this Shlink server.</p>
|
|
||||||
Make sure you have internet connection, and the server is properly configured and on-line.
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ServersListGroup servers={Object.values(servers)}>
|
|
||||||
These are the Shlink servers currently configured. Choose one of
|
|
||||||
them or <Link to="/server/create">add a new one</Link>.
|
|
||||||
</ServersListGroup>
|
|
||||||
|
|
||||||
{type === 'not-reachable' && (
|
|
||||||
<div className="container mt-3 mt-md-5">
|
|
||||||
<h5>
|
|
||||||
Alternatively, if you think you may have miss-configured this server, you
|
|
||||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
|
||||||
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
ServerErrorComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return ServerErrorComp;
|
|
||||||
};
|
|
45
src/servers/helpers/ServerError.tsx
Normal file
45
src/servers/helpers/ServerError.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Message from '../../utils/Message';
|
||||||
|
import ServersListGroup from '../ServersListGroup';
|
||||||
|
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||||
|
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||||
|
import './ServerError.scss';
|
||||||
|
|
||||||
|
interface ServerErrorProps {
|
||||||
|
servers: ServersMap;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
|
||||||
|
{ servers, selectedServer },
|
||||||
|
) => (
|
||||||
|
<div className="server-error__container flex-column">
|
||||||
|
<div className="row w-100 mb-3 mb-md-5">
|
||||||
|
<Message type="error">
|
||||||
|
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||||
|
{isServerWithId(selectedServer) && (
|
||||||
|
<React.Fragment>
|
||||||
|
<p>Oops! Could not connect to this Shlink server.</p>
|
||||||
|
Make sure you have internet connection, and the server is properly configured and on-line.
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ServersListGroup servers={Object.values(servers)}>
|
||||||
|
These are the Shlink servers currently configured. Choose one of
|
||||||
|
them or <Link to="/server/create">add a new one</Link>.
|
||||||
|
</ServersListGroup>
|
||||||
|
|
||||||
|
{isServerWithId(selectedServer) && (
|
||||||
|
<div className="container mt-3 mt-md-5">
|
||||||
|
<h5>
|
||||||
|
Alternatively, if you think you may have miss-configured this server, you
|
||||||
|
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||||
|
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
|
@ -1,25 +1,18 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { FC, useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
||||||
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
|
import { ServerData } from '../data';
|
||||||
|
|
||||||
const propTypes = {
|
interface ServerFormProps {
|
||||||
onSubmit: PropTypes.func.isRequired,
|
onSubmit: (server: ServerData) => void;
|
||||||
initialValues: PropTypes.shape({
|
initialValues?: ServerData;
|
||||||
name: PropTypes.string.isRequired,
|
}
|
||||||
url: PropTypes.string.isRequired,
|
|
||||||
apiKey: PropTypes.string.isRequired,
|
|
||||||
}),
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ServerForm = ({ onSubmit, initialValues, children }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children }) => {
|
||||||
const [ name, setName ] = useState('');
|
const [ name, setName ] = useState('');
|
||||||
const [ url, setUrl ] = useState('');
|
const [ url, setUrl ] = useState('');
|
||||||
const [ apiKey, setApiKey ] = useState('');
|
const [ apiKey, setApiKey ] = useState('');
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
|
||||||
e.preventDefault();
|
|
||||||
onSubmit({ name, url, apiKey });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialValues && setName(initialValues.name);
|
initialValues && setName(initialValues.name);
|
||||||
|
@ -37,5 +30,3 @@ export const ServerForm = ({ onSubmit, initialValues, children }) => {
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ServerForm.propTypes = propTypes;
|
|
|
@ -1,35 +0,0 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import Message from '../../utils/Message';
|
|
||||||
import { serverType } from '../prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
selectServer: PropTypes.func,
|
|
||||||
selectedServer: serverType,
|
|
||||||
match: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const withSelectedServer = (WrappedComponent, ServerError) => {
|
|
||||||
const Component = (props) => {
|
|
||||||
const { selectServer, selectedServer, match } = props;
|
|
||||||
const { params: { serverId } } = match;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectServer(serverId);
|
|
||||||
}, [ serverId ]);
|
|
||||||
|
|
||||||
if (!selectedServer) {
|
|
||||||
return <Message loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedServer.serverNotFound) {
|
|
||||||
return <ServerError type="not-found" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <WrappedComponent {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
Component.propTypes = propTypes;
|
|
||||||
|
|
||||||
return Component;
|
|
||||||
};
|
|
29
src/servers/helpers/withSelectedServer.tsx
Normal file
29
src/servers/helpers/withSelectedServer.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React, { FC, useEffect } from 'react';
|
||||||
|
import { RouteComponentProps } from 'react-router';
|
||||||
|
import Message from '../../utils/Message';
|
||||||
|
import { isNotFoundServer, SelectedServer } from '../data';
|
||||||
|
|
||||||
|
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||||
|
selectServer: (serverId: string) => void;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
|
||||||
|
return (props: WithSelectedServerProps & T) => {
|
||||||
|
const { selectServer, selectedServer, match } = props;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectServer(match.params.serverId);
|
||||||
|
}, [ match.params.serverId ]);
|
||||||
|
|
||||||
|
if (!selectedServer) {
|
||||||
|
return <Message loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNotFoundServer(selectedServer)) {
|
||||||
|
return <ServerError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WrappedComponent {...props} />;
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const regularServerType = PropTypes.shape({
|
|
||||||
id: PropTypes.string,
|
|
||||||
name: PropTypes.string,
|
|
||||||
url: PropTypes.string,
|
|
||||||
apiKey: PropTypes.string,
|
|
||||||
version: PropTypes.string,
|
|
||||||
printableVersion: PropTypes.string,
|
|
||||||
serverNotReachable: PropTypes.bool,
|
|
||||||
});
|
|
||||||
|
|
||||||
const notFoundServerType = PropTypes.shape({
|
|
||||||
serverNotFound: PropTypes.bool.isRequired,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const serverType = PropTypes.oneOfType([
|
|
||||||
regularServerType,
|
|
||||||
notFoundServerType,
|
|
||||||
]);
|
|
|
@ -1,19 +1,22 @@
|
||||||
import { pipe, prop } from 'ramda';
|
import { pipe, prop } from 'ramda';
|
||||||
|
import { AxiosInstance } from 'axios';
|
||||||
|
import { Dispatch } from 'redux';
|
||||||
import { homepage } from '../../../package.json';
|
import { homepage } from '../../../package.json';
|
||||||
|
import { ServerData } from '../data';
|
||||||
import { createServers } from './servers';
|
import { createServers } from './servers';
|
||||||
|
|
||||||
const responseToServersList = pipe(
|
const responseToServersList = pipe(
|
||||||
prop('data'),
|
prop<any, any>('data'),
|
||||||
(value) => {
|
(data: any): ServerData[] => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(data)) {
|
||||||
throw new Error('Value is not an array');
|
throw new Error('Value is not an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return data as ServerData[];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchServers = ({ get }) => () => async (dispatch) => {
|
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||||
const remoteList = await get(`${homepage}/servers.json`)
|
const remoteList = await get(`${homepage}/servers.json`)
|
||||||
.then(responseToServersList)
|
.then(responseToServersList)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
|
@ -1,7 +1,12 @@
|
||||||
import { createAction, handleActions } from 'redux-actions';
|
|
||||||
import { identity, memoizeWith, pipe } from 'ramda';
|
import { identity, memoizeWith, pipe } from 'ramda';
|
||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||||
|
import { SelectedServer } from '../data';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
import { ShlinkHealth } from '../../utils/services/types';
|
||||||
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||||
|
@ -12,22 +17,40 @@ export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
const initialState = null;
|
export interface SelectServerAction extends Action<string> {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
const versionToSemVer = pipe(
|
const versionToSemVer = pipe(
|
||||||
(version) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
|
(version: string) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
|
||||||
toSemVer(MIN_FALLBACK_VERSION)
|
toSemVer(MIN_FALLBACK_VERSION),
|
||||||
);
|
);
|
||||||
|
|
||||||
const getServerVersion = memoizeWith(identity, (serverId, health) => health().then(({ version }) => ({
|
const getServerVersion = memoizeWith(
|
||||||
|
identity,
|
||||||
|
async (_serverId: string, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
|
||||||
version: versionToSemVer(version),
|
version: versionToSemVer(version),
|
||||||
printableVersion: versionToPrintable(version),
|
printableVersion: versionToPrintable(version),
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
|
const initialState: SelectedServer = null;
|
||||||
|
|
||||||
export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId) => async (
|
export default buildReducer<SelectedServer, SelectServerAction>({
|
||||||
dispatch,
|
[RESET_SELECTED_SERVER]: () => initialState,
|
||||||
getState
|
[SELECT_SERVER]: (_, { selectedServer }) => selectedServer,
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER);
|
||||||
|
|
||||||
|
export const selectServer = (
|
||||||
|
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||||
|
loadMercureInfo: () => Action,
|
||||||
|
) => (
|
||||||
|
serverId: string,
|
||||||
|
) => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
) => {
|
) => {
|
||||||
dispatch(resetSelectedServer());
|
dispatch(resetSelectedServer());
|
||||||
dispatch(resetShortUrlParams());
|
dispatch(resetShortUrlParams());
|
||||||
|
@ -36,7 +59,7 @@ export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId
|
||||||
const selectedServer = servers[serverId];
|
const selectedServer = servers[serverId];
|
||||||
|
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
dispatch({
|
dispatch<SelectServerAction>({
|
||||||
type: SELECT_SERVER,
|
type: SELECT_SERVER,
|
||||||
selectedServer: { serverNotFound: true },
|
selectedServer: { serverNotFound: true },
|
||||||
});
|
});
|
||||||
|
@ -48,7 +71,7 @@ export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId
|
||||||
const { health } = buildShlinkApiClient(selectedServer);
|
const { health } = buildShlinkApiClient(selectedServer);
|
||||||
const { version, printableVersion } = await getServerVersion(serverId, health);
|
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||||
|
|
||||||
dispatch({
|
dispatch<SelectServerAction>({
|
||||||
type: SELECT_SERVER,
|
type: SELECT_SERVER,
|
||||||
selectedServer: {
|
selectedServer: {
|
||||||
...selectedServer,
|
...selectedServer,
|
||||||
|
@ -58,14 +81,9 @@ export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId
|
||||||
});
|
});
|
||||||
dispatch(loadMercureInfo());
|
dispatch(loadMercureInfo());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch<SelectServerAction>({
|
||||||
type: SELECT_SERVER,
|
type: SELECT_SERVER,
|
||||||
selectedServer: { ...selectedServer, serverNotReachable: true },
|
selectedServer: { ...selectedServer, serverNotReachable: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default handleActions({
|
|
||||||
[RESET_SELECTED_SERVER]: () => initialState,
|
|
||||||
[SELECT_SERVER]: (state, { selectedServer }) => selectedServer,
|
|
||||||
}, initialState);
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { handleActions } from 'redux-actions';
|
|
||||||
import { pipe, assoc, map, reduce, dissoc } from 'ramda';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
|
||||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
|
||||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
const initialState = {};
|
|
||||||
|
|
||||||
const assocId = (server) => assoc('id', server.id || uuid(), server);
|
|
||||||
|
|
||||||
export default handleActions({
|
|
||||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
|
||||||
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
|
||||||
[EDIT_SERVER]: (state, { serverId, serverData }) => !state[serverId]
|
|
||||||
? state
|
|
||||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const createServer = (server) => createServers([ server ]);
|
|
||||||
|
|
||||||
const serversListToMap = reduce((acc, server) => assoc(server.id, server, acc), {});
|
|
||||||
|
|
||||||
export const createServers = pipe(
|
|
||||||
map(assocId),
|
|
||||||
serversListToMap,
|
|
||||||
(newServers) => ({ type: CREATE_SERVERS, newServers })
|
|
||||||
);
|
|
||||||
|
|
||||||
export const editServer = (serverId, serverData) => ({ type: EDIT_SERVER, serverId, serverData });
|
|
||||||
|
|
||||||
export const deleteServer = ({ id }) => ({ type: DELETE_SERVER, serverId: id });
|
|
51
src/servers/reducers/servers.ts
Normal file
51
src/servers/reducers/servers.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { Action } from 'redux';
|
||||||
|
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||||
|
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||||
|
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export interface CreateServersAction extends Action<string> {
|
||||||
|
newServers: ServersMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ServersMap = {};
|
||||||
|
|
||||||
|
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||||
|
if ((server as ServerWithId).id) {
|
||||||
|
return server as ServerWithId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assoc('id', uuid(), server);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildReducer<ServersMap, CreateServersAction>({
|
||||||
|
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||||
|
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
|
||||||
|
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||||
|
? state
|
||||||
|
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||||
|
|
||||||
|
export const createServers = pipe(
|
||||||
|
map(serverWithId),
|
||||||
|
serversListToMap,
|
||||||
|
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createServer = (server: ServerWithId) => createServers([ server ]);
|
||||||
|
|
||||||
|
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
|
||||||
|
type: EDIT_SERVER,
|
||||||
|
serverId,
|
||||||
|
serverData,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
|
|
@ -1,6 +1,9 @@
|
||||||
import { dissoc, head, keys, values } from 'ramda';
|
import { dissoc, head, keys, values } from 'ramda';
|
||||||
|
import { CsvJson } from 'csvjson';
|
||||||
|
import LocalStorage from '../../utils/services/LocalStorage';
|
||||||
|
import { ServersMap } from '../data';
|
||||||
|
|
||||||
const saveCsv = (window, csv) => {
|
const saveCsv = (window: Window, csv: string) => {
|
||||||
const { navigator, document } = window;
|
const { navigator, document } = window;
|
||||||
const filename = 'shlink-servers.csv';
|
const filename = 'shlink-servers.csv';
|
||||||
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
@ -25,14 +28,14 @@ const saveCsv = (window, csv) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ServersExporter {
|
export default class ServersExporter {
|
||||||
constructor(storage, window, csvjson) {
|
public constructor(
|
||||||
this.storage = storage;
|
private readonly storage: LocalStorage,
|
||||||
this.window = window;
|
private readonly window: Window,
|
||||||
this.csvjson = csvjson;
|
private readonly csvjson: CsvJson,
|
||||||
}
|
) {}
|
||||||
|
|
||||||
exportServers = async () => {
|
public readonly exportServers = async () => {
|
||||||
const servers = values(this.storage.get('servers') || {}).map(dissoc('id'));
|
const servers = values(this.storage.get<ServersMap>('servers') || {}).map(dissoc('id'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = this.csvjson.toCSV(servers, {
|
const csv = this.csvjson.toCSV(servers, {
|
|
@ -1,23 +0,0 @@
|
||||||
export default 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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
26
src/servers/services/ServersImporter.ts
Normal file
26
src/servers/services/ServersImporter.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { CsvJson } from 'csvjson';
|
||||||
|
import { ServerData } from '../data';
|
||||||
|
|
||||||
|
const CSV_MIME_TYPE = 'text/csv';
|
||||||
|
|
||||||
|
export default class ServersImporter {
|
||||||
|
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||||
|
|
||||||
|
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||||
|
if (!file || file.type !== CSV_MIME_TYPE) {
|
||||||
|
throw new Error('No file provided or file is not a CSV');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = this.fileReaderFactory();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
||||||
|
const content = e.target?.result?.toString() ?? '';
|
||||||
|
const servers = this.csvjson.toObject<ServerData>(content);
|
||||||
|
|
||||||
|
resolve(servers);
|
||||||
|
});
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import csvjson from 'csvjson';
|
import csvjson from 'csvjson';
|
||||||
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import CreateServer from '../CreateServer';
|
import CreateServer from '../CreateServer';
|
||||||
import ServersDropdown from '../ServersDropdown';
|
import ServersDropdown from '../ServersDropdown';
|
||||||
import DeleteServerModal from '../DeleteServerModal';
|
import DeleteServerModal from '../DeleteServerModal';
|
||||||
|
@ -10,10 +11,11 @@ import { createServer, createServers, deleteServer, editServer } from '../reduce
|
||||||
import { fetchServers } from '../reducers/remoteServers';
|
import { fetchServers } from '../reducers/remoteServers';
|
||||||
import ForServerVersion from '../helpers/ForServerVersion';
|
import ForServerVersion from '../helpers/ForServerVersion';
|
||||||
import { ServerError } from '../helpers/ServerError';
|
import { ServerError } from '../helpers/ServerError';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import ServersImporter from './ServersImporter';
|
import ServersImporter from './ServersImporter';
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
const provideServices = (bottle, connect, withRouter) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||||
|
@ -41,7 +43,8 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.constant('csvjson', csvjson);
|
bottle.constant('csvjson', csvjson);
|
||||||
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
bottle.constant('fileReaderFactory', () => new FileReader());
|
||||||
|
bottle.service('ServersImporter', ServersImporter, 'csvjson', 'fileReaderFactory');
|
||||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
|
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
|
@ -1,15 +1,14 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardBody, CardHeader } from 'reactstrap';
|
import { Card, CardBody, CardHeader } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { SettingsType } from './reducers/settings';
|
import { Settings } from './reducers/settings';
|
||||||
|
|
||||||
const propTypes = {
|
interface RealTimeUpdatesProps {
|
||||||
settings: SettingsType,
|
settings: Settings;
|
||||||
setRealTimeUpdates: PropTypes.func,
|
setRealTimeUpdates: (enabled: boolean) => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
const RealTimeUpdates = ({ settings: { realTimeUpdates }, setRealTimeUpdates }) => (
|
const RealTimeUpdates = ({ settings: { realTimeUpdates }, setRealTimeUpdates }: RealTimeUpdatesProps) => (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>Real-time updates</CardHeader>
|
<CardHeader>Real-time updates</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
|
@ -20,6 +19,4 @@ const RealTimeUpdates = ({ settings: { realTimeUpdates }, setRealTimeUpdates })
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
RealTimeUpdates.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default RealTimeUpdates;
|
export default RealTimeUpdates;
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React, { FC } from 'react';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
|
||||||
const Settings = (RealTimeUpdates) => () => (
|
const Settings = (RealTimeUpdates: FC) => () => (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<RealTimeUpdates />
|
<RealTimeUpdates />
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
|
@ -1,25 +0,0 @@
|
||||||
import { handleActions } from 'redux-actions';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES';
|
|
||||||
|
|
||||||
export const SettingsType = PropTypes.shape({
|
|
||||||
realTimeUpdates: PropTypes.shape({
|
|
||||||
enabled: PropTypes.bool.isRequired,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
realTimeUpdates: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default handleActions({
|
|
||||||
[SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => ({ ...state, realTimeUpdates }),
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const setRealTimeUpdates = (enabled) => ({
|
|
||||||
type: SET_REAL_TIME_UPDATES,
|
|
||||||
realTimeUpdates: { enabled },
|
|
||||||
});
|
|
29
src/settings/reducers/settings.ts
Normal file
29
src/settings/reducers/settings.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { Action } from 'redux';
|
||||||
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
|
export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES';
|
||||||
|
|
||||||
|
interface RealTimeUpdates {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
realTimeUpdates: RealTimeUpdates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: Settings = {
|
||||||
|
realTimeUpdates: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsAction = Action & Settings;
|
||||||
|
|
||||||
|
export default buildReducer<Settings, SettingsAction>({
|
||||||
|
[SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => ({ ...state, realTimeUpdates }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const setRealTimeUpdates = (enabled: boolean): SettingsAction => ({
|
||||||
|
type: SET_REAL_TIME_UPDATES,
|
||||||
|
realTimeUpdates: { enabled },
|
||||||
|
});
|
|
@ -1,11 +1,14 @@
|
||||||
|
import Bottle from 'bottlejs';
|
||||||
import RealTimeUpdates from '../RealTimeUpdates';
|
import RealTimeUpdates from '../RealTimeUpdates';
|
||||||
import Settings from '../Settings';
|
import Settings from '../Settings';
|
||||||
import { setRealTimeUpdates } from '../reducers/settings';
|
import { setRealTimeUpdates } from '../reducers/settings';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
const provideServices = (bottle, connect) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
|
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
|
||||||
|
|
||||||
|
// Services
|
||||||
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
||||||
bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdates' ]));
|
bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdates' ]));
|
||||||
|
|
|
@ -1,173 +0,0 @@
|
||||||
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
|
||||||
import * as PropTypes from 'prop-types';
|
|
||||||
import DateInput from '../utils/DateInput';
|
|
||||||
import Checkbox from '../utils/Checkbox';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import { versionMatch } from '../utils/helpers/version';
|
|
||||||
import { hasValue } from '../utils/utils';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
|
||||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
|
||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
|
||||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
createShortUrl: PropTypes.func,
|
|
||||||
shortUrlCreationResult: createShortUrlResultType,
|
|
||||||
resetCreateShortUrl: PropTypes.func,
|
|
||||||
selectedServer: serverType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
longUrl: '',
|
|
||||||
tags: [],
|
|
||||||
customSlug: '',
|
|
||||||
shortCodeLength: '',
|
|
||||||
domain: '',
|
|
||||||
validSince: undefined,
|
|
||||||
validUntil: undefined,
|
|
||||||
maxVisits: '',
|
|
||||||
findIfExists: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
|
|
||||||
const CreateShortUrlComp = ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }) => {
|
|
||||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
|
||||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
|
||||||
|
|
||||||
const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
|
||||||
const reset = () => setShortUrlCreation(initialState);
|
|
||||||
const save = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const shortUrlData = {
|
|
||||||
...shortUrlCreation,
|
|
||||||
validSince: formatDate(shortUrlCreation.validSince),
|
|
||||||
validUntil: formatDate(shortUrlCreation.validUntil),
|
|
||||||
};
|
|
||||||
|
|
||||||
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
|
||||||
};
|
|
||||||
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
|
||||||
<FormGroup>
|
|
||||||
<Input
|
|
||||||
id={id}
|
|
||||||
type={type}
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={shortUrlCreation[id]}
|
|
||||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
|
||||||
const renderDateInput = (id, placeholder, props = {}) => (
|
|
||||||
<div className="form-group">
|
|
||||||
<DateInput
|
|
||||||
selected={shortUrlCreation[id]}
|
|
||||||
placeholderText={placeholder}
|
|
||||||
isClearable
|
|
||||||
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentServerVersion = selectedServer && selectedServer.version;
|
|
||||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
|
||||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={save}>
|
|
||||||
<div className="form-group">
|
|
||||||
<input
|
|
||||||
className="form-control form-control-lg"
|
|
||||||
type="url"
|
|
||||||
placeholder="Insert the URL to be shortened"
|
|
||||||
required
|
|
||||||
value={shortUrlCreation.longUrl}
|
|
||||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapse isOpen={moreOptionsVisible}>
|
|
||||||
<div className="form-group">
|
|
||||||
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-sm-4">
|
|
||||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-4">
|
|
||||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
|
||||||
min: 4,
|
|
||||||
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
|
||||||
...disableShortCodeLength && {
|
|
||||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-4">
|
|
||||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
|
||||||
disabled: disableDomain,
|
|
||||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-sm-4">
|
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-4">
|
|
||||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-4">
|
|
||||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ForServerVersion minVersion="1.16.0">
|
|
||||||
<div className="mb-4 text-right">
|
|
||||||
<Checkbox
|
|
||||||
className="mr-2"
|
|
||||||
checked={shortUrlCreation.findIfExists}
|
|
||||||
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
|
||||||
>
|
|
||||||
Use existing URL if found
|
|
||||||
</Checkbox>
|
|
||||||
<UseExistingIfFoundInfoIcon />
|
|
||||||
</div>
|
|
||||||
</ForServerVersion>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
|
|
||||||
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
|
|
||||||
|
|
||||||
{moreOptionsVisible ? 'Less' : 'More'} options
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary float-right"
|
|
||||||
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
|
||||||
>
|
|
||||||
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CreateShortUrlComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return CreateShortUrlComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateShortUrl;
|
|
178
src/short-urls/CreateShortUrl.tsx
Normal file
178
src/short-urls/CreateShortUrl.tsx
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { isEmpty, pipe, replace, trim } from 'ramda';
|
||||||
|
import React, { FC, useState } from 'react';
|
||||||
|
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||||
|
import { InputType } from 'reactstrap/lib/Input';
|
||||||
|
import * as m from 'moment';
|
||||||
|
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||||
|
import Checkbox from '../utils/Checkbox';
|
||||||
|
import { versionMatch, Versions } from '../utils/helpers/version';
|
||||||
|
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
|
import { ShortUrlData } from './data';
|
||||||
|
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||||
|
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||||
|
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
||||||
|
|
||||||
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
|
|
||||||
|
interface CreateShortUrlProps {
|
||||||
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
createShortUrl: Function;
|
||||||
|
resetCreateShortUrl: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ShortUrlData = {
|
||||||
|
longUrl: '',
|
||||||
|
tags: [],
|
||||||
|
customSlug: '',
|
||||||
|
shortCodeLength: undefined,
|
||||||
|
domain: '',
|
||||||
|
validSince: undefined,
|
||||||
|
validUntil: undefined,
|
||||||
|
maxVisits: undefined,
|
||||||
|
findIfExists: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
|
||||||
|
type DateFields = 'validSince' | 'validUntil';
|
||||||
|
|
||||||
|
const CreateShortUrl = (
|
||||||
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
|
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||||
|
ForServerVersion: FC<Versions>,
|
||||||
|
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
||||||
|
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||||
|
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
||||||
|
|
||||||
|
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
||||||
|
const reset = () => setShortUrlCreation(initialState);
|
||||||
|
const save = handleEventPreventingDefault(() => {
|
||||||
|
const shortUrlData = {
|
||||||
|
...shortUrlCreation,
|
||||||
|
validSince: formatIsoDate(shortUrlCreation.validSince),
|
||||||
|
validUntil: formatIsoDate(shortUrlCreation.validUntil),
|
||||||
|
};
|
||||||
|
|
||||||
|
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
||||||
|
});
|
||||||
|
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
|
||||||
|
<FormGroup>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={shortUrlCreation[id]}
|
||||||
|
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
||||||
|
<div className="form-group">
|
||||||
|
<DateInput
|
||||||
|
selected={shortUrlCreation[id] as m.Moment | null}
|
||||||
|
placeholderText={placeholder}
|
||||||
|
isClearable
|
||||||
|
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
||||||
|
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||||
|
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
className="form-control form-control-lg"
|
||||||
|
type="url"
|
||||||
|
placeholder="Insert the URL to be shortened"
|
||||||
|
required
|
||||||
|
value={shortUrlCreation.longUrl}
|
||||||
|
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapse isOpen={moreOptionsVisible}>
|
||||||
|
<div className="form-group">
|
||||||
|
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-4">
|
||||||
|
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
||||||
|
disabled: hasValue(shortUrlCreation.shortCodeLength),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||||
|
min: 4,
|
||||||
|
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||||
|
...disableShortCodeLength && {
|
||||||
|
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||||
|
disabled: disableDomain,
|
||||||
|
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-4">
|
||||||
|
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ForServerVersion minVersion="1.16.0">
|
||||||
|
<div className="mb-4 text-right">
|
||||||
|
<Checkbox
|
||||||
|
className="mr-2"
|
||||||
|
checked={shortUrlCreation.findIfExists}
|
||||||
|
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||||
|
>
|
||||||
|
Use existing URL if found
|
||||||
|
</Checkbox>
|
||||||
|
<UseExistingIfFoundInfoIcon />
|
||||||
|
</div>
|
||||||
|
</ForServerVersion>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
|
||||||
|
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
|
||||||
|
|
||||||
|
{moreOptionsVisible ? 'Less' : 'More'} options
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary float-right"
|
||||||
|
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
||||||
|
>
|
||||||
|
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateShortUrl;
|
|
@ -1,20 +1,17 @@
|
||||||
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 { pageIsEllipsis, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
import { ShlinkPaginator } from '../utils/services/types';
|
||||||
import './Paginator.scss';
|
import './Paginator.scss';
|
||||||
|
|
||||||
const propTypes = {
|
interface PaginatorProps {
|
||||||
serverId: PropTypes.string.isRequired,
|
paginator?: ShlinkPaginator;
|
||||||
paginator: PropTypes.shape({
|
serverId: string;
|
||||||
currentPage: PropTypes.number,
|
}
|
||||||
pagesCount: PropTypes.number,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const Paginator = ({ paginator = {}, serverId }) => {
|
const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||||
const { currentPage, pagesCount = 0 } = paginator;
|
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -24,7 +21,7 @@ const Paginator = ({ paginator = {}, serverId }) => {
|
||||||
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={keyForPage(pageNumber, index)}
|
key={keyForPage(pageNumber, index)}
|
||||||
disabled={isPageDisabled(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
|
@ -57,6 +54,4 @@ const Paginator = ({ paginator = {}, serverId }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Paginator.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default Paginator;
|
export default Paginator;
|
|
@ -1,81 +0,0 @@
|
||||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import React from 'react';
|
|
||||||
import { isEmpty, pipe } from 'ramda';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import moment from 'moment';
|
|
||||||
import SearchField from '../utils/SearchField';
|
|
||||||
import Tag from '../tags/helpers/Tag';
|
|
||||||
import DateRangeRow from '../utils/DateRangeRow';
|
|
||||||
import { formatDate } from '../utils/helpers/date';
|
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
|
||||||
import './SearchBar.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
listShortUrls: PropTypes.func,
|
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const dateOrUndefined = (date) => date ? moment(date) : undefined;
|
|
||||||
|
|
||||||
const SearchBar = (colorGenerator, ForServerVersion) => {
|
|
||||||
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
|
||||||
const selectedTags = shortUrlsListParams.tags || [];
|
|
||||||
const setDate = (dateName) => pipe(
|
|
||||||
formatDate(),
|
|
||||||
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date })
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="search-bar-container">
|
|
||||||
<SearchField
|
|
||||||
onChange={
|
|
||||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ForServerVersion minVersion="1.21.0">
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
|
||||||
<DateRangeRow
|
|
||||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
|
||||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
|
||||||
onStartDateChange={setDate('startDate')}
|
|
||||||
onEndDateChange={setDate('endDate')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ForServerVersion>
|
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
|
||||||
|
|
||||||
{selectedTags.map((tag) => (
|
|
||||||
<Tag
|
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
key={tag}
|
|
||||||
text={tag}
|
|
||||||
clearable
|
|
||||||
onClose={() => listShortUrls(
|
|
||||||
{
|
|
||||||
...shortUrlsListParams,
|
|
||||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SearchBar.propTypes = propTypes;
|
|
||||||
|
|
||||||
return SearchBar;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchBar;
|
|
78
src/short-urls/SearchBar.tsx
Normal file
78
src/short-urls/SearchBar.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { isEmpty, pipe } from 'ramda';
|
||||||
|
import moment from 'moment';
|
||||||
|
import SearchField from '../utils/SearchField';
|
||||||
|
import Tag from '../tags/helpers/Tag';
|
||||||
|
import DateRangeRow from '../utils/DateRangeRow';
|
||||||
|
import { formatDate } from '../utils/helpers/date';
|
||||||
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
|
import { Versions } from '../utils/helpers/version';
|
||||||
|
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
||||||
|
import './SearchBar.scss';
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
|
shortUrlsListParams: ShortUrlsListParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOrNull = (date?: string) => date ? moment(date) : null;
|
||||||
|
|
||||||
|
const SearchBar = (colorGenerator: ColorGenerator, ForServerVersion: FC<Versions>) => (
|
||||||
|
{ listShortUrls, shortUrlsListParams }: SearchBarProps,
|
||||||
|
) => {
|
||||||
|
const selectedTags = shortUrlsListParams.tags ?? [];
|
||||||
|
const setDate = (dateName: 'startDate' | 'endDate') => pipe(
|
||||||
|
formatDate(),
|
||||||
|
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-bar-container">
|
||||||
|
<SearchField
|
||||||
|
onChange={
|
||||||
|
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ForServerVersion minVersion="1.21.0">
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||||
|
<DateRangeRow
|
||||||
|
startDate={dateOrNull(shortUrlsListParams.startDate)}
|
||||||
|
endDate={dateOrNull(shortUrlsListParams.endDate)}
|
||||||
|
onStartDateChange={setDate('startDate')}
|
||||||
|
onEndDateChange={setDate('endDate')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ForServerVersion>
|
||||||
|
|
||||||
|
{!isEmpty(selectedTags) && (
|
||||||
|
<h4 className="search-bar__selected-tag mt-3">
|
||||||
|
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||||
|
|
||||||
|
{selectedTags.map((tag) => (
|
||||||
|
<Tag
|
||||||
|
colorGenerator={colorGenerator}
|
||||||
|
key={tag}
|
||||||
|
text={tag}
|
||||||
|
clearable
|
||||||
|
onClose={() => listShortUrls(
|
||||||
|
{
|
||||||
|
...shortUrlsListParams,
|
||||||
|
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchBar;
|
|
@ -1,41 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import Paginator from './Paginator';
|
|
||||||
|
|
||||||
const ShortUrls = (SearchBar, ShortUrlsList) => {
|
|
||||||
const propTypes = {
|
|
||||||
match: PropTypes.shape({
|
|
||||||
params: PropTypes.object,
|
|
||||||
}),
|
|
||||||
shortUrlsList: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShortUrlsComponent = (props) => {
|
|
||||||
const { match: { params }, shortUrlsList } = props;
|
|
||||||
const { page, serverId } = params;
|
|
||||||
const { data = [], pagination } = shortUrlsList;
|
|
||||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
|
||||||
|
|
||||||
// Using a key on a component makes react to create a new instance every time the key changes
|
|
||||||
// Without it, pagination on the URL will not make the component to be refreshed
|
|
||||||
useEffect(() => {
|
|
||||||
setUrlsListKey(`${serverId}_${page}`);
|
|
||||||
}, [ serverId, page ]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<div className="form-group"><SearchBar /></div>
|
|
||||||
<div>
|
|
||||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
|
||||||
<Paginator paginator={pagination} serverId={serverId} />
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ShortUrlsComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
return ShortUrlsComponent;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortUrls;
|
|
33
src/short-urls/ShortUrls.tsx
Normal file
33
src/short-urls/ShortUrls.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import React, { FC, useEffect, useState } from 'react';
|
||||||
|
import { ShlinkShortUrlsResponse } from '../utils/services/types';
|
||||||
|
import Paginator from './Paginator';
|
||||||
|
import { ShortUrlsListProps, WithList } from './ShortUrlsList';
|
||||||
|
|
||||||
|
interface ShortUrlsProps extends ShortUrlsListProps {
|
||||||
|
shortUrlsList?: ShlinkShortUrlsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithList>) => (props: ShortUrlsProps) => {
|
||||||
|
const { match, shortUrlsList } = props;
|
||||||
|
const { page = '1', serverId = '' } = match?.params ?? {};
|
||||||
|
const { data = [], pagination } = shortUrlsList ?? {};
|
||||||
|
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
||||||
|
|
||||||
|
// Using a key on a component makes react to create a new instance every time the key changes
|
||||||
|
// Without it, pagination on the URL will not make the component to be refreshed
|
||||||
|
useEffect(() => {
|
||||||
|
setUrlsListKey(`${serverId}_${page}`);
|
||||||
|
}, [ serverId, page ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className="form-group"><SearchBar /></div>
|
||||||
|
<div>
|
||||||
|
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||||
|
<Paginator paginator={pagination} serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortUrls;
|
|
@ -1,178 +0,0 @@
|
||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { head, isEmpty, keys, values } from 'ramda';
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import qs from 'qs';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
|
||||||
import { determineOrderDir } from '../utils/utils';
|
|
||||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
|
||||||
import { useMercureTopicBinding } from '../mercure/helpers';
|
|
||||||
import { shortUrlType } from './reducers/shortUrlsList';
|
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
|
||||||
import './ShortUrlsList.scss';
|
|
||||||
|
|
||||||
export const SORTABLE_FIELDS = {
|
|
||||||
dateCreated: 'Created at',
|
|
||||||
shortCode: 'Short URL',
|
|
||||||
longUrl: 'Long URL',
|
|
||||||
visits: 'Visits',
|
|
||||||
};
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
listShortUrls: PropTypes.func,
|
|
||||||
resetShortUrlParams: PropTypes.func,
|
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
|
||||||
match: PropTypes.object,
|
|
||||||
location: PropTypes.object,
|
|
||||||
loading: PropTypes.bool,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
|
||||||
selectedServer: serverType,
|
|
||||||
createNewVisit: PropTypes.func,
|
|
||||||
loadMercureInfo: PropTypes.func,
|
|
||||||
mercureInfo: MercureInfoType,
|
|
||||||
};
|
|
||||||
|
|
||||||
// FIXME Replace with typescript: (ShortUrlsRow component)
|
|
||||||
const ShortUrlsList = (ShortUrlsRow) => {
|
|
||||||
const ShortUrlsListComp = ({
|
|
||||||
listShortUrls,
|
|
||||||
resetShortUrlParams,
|
|
||||||
shortUrlsListParams,
|
|
||||||
match,
|
|
||||||
location,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
shortUrlsList,
|
|
||||||
selectedServer,
|
|
||||||
createNewVisit,
|
|
||||||
loadMercureInfo,
|
|
||||||
mercureInfo,
|
|
||||||
}) => {
|
|
||||||
const { orderBy } = shortUrlsListParams;
|
|
||||||
const [ order, setOrder ] = useState({
|
|
||||||
orderField: orderBy && head(keys(orderBy)),
|
|
||||||
orderDir: orderBy && head(values(orderBy)),
|
|
||||||
});
|
|
||||||
const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
|
||||||
const handleOrderBy = (orderField, orderDir) => {
|
|
||||||
setOrder({ orderField, orderDir });
|
|
||||||
refreshList({ orderBy: { [orderField]: orderDir } });
|
|
||||||
};
|
|
||||||
const orderByColumn = (columnName) => () =>
|
|
||||||
handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir));
|
|
||||||
const renderOrderIcon = (field) => {
|
|
||||||
if (order.orderField !== field) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!order.orderDir) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
|
||||||
className="short-urls-list__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const renderShortUrls = () => {
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loading && isEmpty(shortUrlsList)) {
|
|
||||||
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return shortUrlsList.map((shortUrl) => (
|
|
||||||
<ShortUrlsRow
|
|
||||||
key={shortUrl.shortUrl}
|
|
||||||
shortUrl={shortUrl}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
refreshList={refreshList}
|
|
||||||
shortUrlsListParams={shortUrlsListParams}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { params } = match;
|
|
||||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
|
||||||
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
|
|
||||||
|
|
||||||
refreshList({ page: params.page, tags });
|
|
||||||
|
|
||||||
return resetShortUrlParams;
|
|
||||||
}, []);
|
|
||||||
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<div className="d-block d-md-none mb-3">
|
|
||||||
<SortingDropdown
|
|
||||||
items={SORTABLE_FIELDS}
|
|
||||||
orderField={order.orderField}
|
|
||||||
orderDir={order.orderDir}
|
|
||||||
onChange={handleOrderBy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<table className="table table-striped table-hover">
|
|
||||||
<thead className="short-urls-list__header">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
|
||||||
onClick={orderByColumn('dateCreated')}
|
|
||||||
>
|
|
||||||
{renderOrderIcon('dateCreated')}
|
|
||||||
Created at
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
|
||||||
onClick={orderByColumn('shortCode')}
|
|
||||||
>
|
|
||||||
{renderOrderIcon('shortCode')}
|
|
||||||
Short URL
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
|
||||||
onClick={orderByColumn('longUrl')}
|
|
||||||
>
|
|
||||||
{renderOrderIcon('longUrl')}
|
|
||||||
Long URL
|
|
||||||
</th>
|
|
||||||
<th className="short-urls-list__header-cell">Tags</th>
|
|
||||||
<th
|
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
|
||||||
onClick={orderByColumn('visits')}
|
|
||||||
>
|
|
||||||
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
|
|
||||||
</th>
|
|
||||||
<th className="short-urls-list__header-cell"> </th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{renderShortUrls()}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ShortUrlsListComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return ShortUrlsListComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortUrlsList;
|
|
173
src/short-urls/ShortUrlsList.tsx
Normal file
173
src/short-urls/ShortUrlsList.tsx
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { head, isEmpty, keys, values } from 'ramda';
|
||||||
|
import React, { useState, useEffect, FC } from 'react';
|
||||||
|
import qs from 'qs';
|
||||||
|
import { RouteComponentProps } from 'react-router';
|
||||||
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
|
import { determineOrderDir, OrderDir } from '../utils/utils';
|
||||||
|
import { MercureBoundProps, useMercureTopicBinding } from '../mercure/helpers';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
|
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
||||||
|
import { ShortUrl } from './data';
|
||||||
|
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
||||||
|
import './ShortUrlsList.scss';
|
||||||
|
|
||||||
|
export const SORTABLE_FIELDS = {
|
||||||
|
dateCreated: 'Created at',
|
||||||
|
shortCode: 'Short URL',
|
||||||
|
longUrl: 'Long URL',
|
||||||
|
visits: 'Visits',
|
||||||
|
};
|
||||||
|
type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
page: string;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithList {
|
||||||
|
shortUrlsList: ShortUrl[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps<RouteParams>, MercureBoundProps {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
|
shortUrlsListParams: ShortUrlsListParams;
|
||||||
|
resetShortUrlParams: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
|
listShortUrls,
|
||||||
|
resetShortUrlParams,
|
||||||
|
shortUrlsListParams,
|
||||||
|
match,
|
||||||
|
location,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
shortUrlsList,
|
||||||
|
selectedServer,
|
||||||
|
createNewVisit,
|
||||||
|
loadMercureInfo,
|
||||||
|
mercureInfo,
|
||||||
|
}: ShortUrlsListProps & WithList) => {
|
||||||
|
const { orderBy } = shortUrlsListParams;
|
||||||
|
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
||||||
|
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||||
|
orderDir: orderBy && head(values(orderBy)),
|
||||||
|
});
|
||||||
|
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
||||||
|
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
||||||
|
setOrder({ orderField, orderDir });
|
||||||
|
refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined });
|
||||||
|
};
|
||||||
|
const orderByColumn = (field: OrderableFields) => () =>
|
||||||
|
handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir));
|
||||||
|
const renderOrderIcon = (field: OrderableFields) => {
|
||||||
|
if (order.orderField !== field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.orderDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||||
|
className="short-urls-list__header-icon"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const renderShortUrls = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && isEmpty(shortUrlsList)) {
|
||||||
|
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortUrlsList.map((shortUrl) => (
|
||||||
|
<ShortUrlsRow
|
||||||
|
key={shortUrl.shortUrl}
|
||||||
|
shortUrl={shortUrl}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
refreshList={refreshList}
|
||||||
|
shortUrlsListParams={shortUrlsListParams}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||||
|
const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags;
|
||||||
|
|
||||||
|
refreshList({ page: match.params.page, tags });
|
||||||
|
|
||||||
|
return resetShortUrlParams;
|
||||||
|
}, []);
|
||||||
|
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className="d-block d-md-none mb-3">
|
||||||
|
<SortingDropdown
|
||||||
|
items={SORTABLE_FIELDS}
|
||||||
|
orderField={order.orderField}
|
||||||
|
orderDir={order.orderDir}
|
||||||
|
onChange={handleOrderBy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<table className="table table-striped table-hover">
|
||||||
|
<thead className="short-urls-list__header">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
|
onClick={orderByColumn('dateCreated')}
|
||||||
|
>
|
||||||
|
{renderOrderIcon('dateCreated')}
|
||||||
|
Created at
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
|
onClick={orderByColumn('shortCode')}
|
||||||
|
>
|
||||||
|
{renderOrderIcon('shortCode')}
|
||||||
|
Short URL
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
|
onClick={orderByColumn('longUrl')}
|
||||||
|
>
|
||||||
|
{renderOrderIcon('longUrl')}
|
||||||
|
Long URL
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-list__header-cell">Tags</th>
|
||||||
|
<th
|
||||||
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
|
onClick={orderByColumn('visits')}
|
||||||
|
>
|
||||||
|
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-list__header-cell"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{renderShortUrls()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortUrlsList;
|
|
@ -2,6 +2,6 @@
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
font-size: 17.5px;
|
font-size: 17.5px;
|
||||||
border-left: 5px solid #eee;
|
border-left: 5px solid #eeeeee;
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||||
import './UseExistingIfFoundInfoIcon.scss';
|
import './UseExistingIfFoundInfoIcon.scss';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
|
||||||
const renderInfoModal = (isOpen, toggle) => (
|
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
||||||
<ModalHeader toggle={toggle}>Info</ModalHeader>
|
<ModalHeader toggle={toggle}>Info</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
|
@ -45,7 +45,7 @@ const UseExistingIfFoundInfoIcon = () => {
|
||||||
<span title="What does this mean?">
|
<span title="What does this mean?">
|
||||||
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
|
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
|
||||||
</span>
|
</span>
|
||||||
{renderInfoModal(isModalOpen, toggleModal)}
|
<InfoModal isOpen={isModalOpen} toggle={toggleModal} />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
42
src/short-urls/data/index.ts
Normal file
42
src/short-urls/data/index.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import * as m from 'moment';
|
||||||
|
import { Nullable, OptionalString } from '../../utils/utils';
|
||||||
|
|
||||||
|
export interface ShortUrlData {
|
||||||
|
longUrl: string;
|
||||||
|
tags?: string[];
|
||||||
|
customSlug?: string;
|
||||||
|
shortCodeLength?: number;
|
||||||
|
domain?: string;
|
||||||
|
validSince?: m.Moment | string;
|
||||||
|
validUntil?: m.Moment | string;
|
||||||
|
maxVisits?: number;
|
||||||
|
findIfExists?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrl {
|
||||||
|
shortCode: string;
|
||||||
|
shortUrl: string;
|
||||||
|
longUrl: string;
|
||||||
|
dateCreated: string;
|
||||||
|
visitsCount: number;
|
||||||
|
meta: Required<Nullable<ShortUrlMeta>>;
|
||||||
|
tags: string[];
|
||||||
|
domain: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlMeta {
|
||||||
|
validSince?: string;
|
||||||
|
validUntil?: string;
|
||||||
|
maxVisits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlModalProps {
|
||||||
|
shortUrl: ShortUrl;
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlIdentifier {
|
||||||
|
shortCode: string;
|
||||||
|
domain: OptionalString;
|
||||||
|
}
|
|
@ -1,67 +0,0 @@
|
||||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { isNil } from 'ramda';
|
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
|
||||||
import { Card, CardBody, Tooltip } from 'reactstrap';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
|
|
||||||
import './CreateShortUrlResult.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
resetCreateShortUrl: PropTypes.func,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
result: createShortUrlResultType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateShortUrlResult = (useStateFlagTimeout) => {
|
|
||||||
const CreateShortUrlResultComp = ({ error, result, resetCreateShortUrl }) => {
|
|
||||||
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetCreateShortUrl();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card body color="danger" inverse className="bg-danger mt-3">
|
|
||||||
An error occurred while creating the URL :(
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNil(result)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shortUrl } = result;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card inverse className="bg-main mt-3">
|
|
||||||
<CardBody>
|
|
||||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
|
||||||
|
|
||||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
|
||||||
<button
|
|
||||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
|
||||||
id="copyBtn"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={copyIcon} /> Copy
|
|
||||||
</button>
|
|
||||||
</CopyToClipboard>
|
|
||||||
|
|
||||||
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
|
||||||
Copied!
|
|
||||||
</Tooltip>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CreateShortUrlResultComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return CreateShortUrlResultComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateShortUrlResult;
|
|
61
src/short-urls/helpers/CreateShortUrlResult.tsx
Normal file
61
src/short-urls/helpers/CreateShortUrlResult.tsx
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { isNil } from 'ramda';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||||
|
import { Card, CardBody, Tooltip } from 'reactstrap';
|
||||||
|
import { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
||||||
|
import { StateFlagTimeout } from '../../utils/helpers/hooks';
|
||||||
|
import './CreateShortUrlResult.scss';
|
||||||
|
|
||||||
|
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
||||||
|
resetCreateShortUrl: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
|
{ error, result, resetCreateShortUrl }: CreateShortUrlResultProps,
|
||||||
|
) => {
|
||||||
|
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetCreateShortUrl();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card body color="danger" inverse className="bg-danger mt-3">
|
||||||
|
An error occurred while creating the URL :(
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(result)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shortUrl } = result;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card inverse className="bg-main mt-3">
|
||||||
|
<CardBody>
|
||||||
|
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||||
|
|
||||||
|
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||||
|
<button
|
||||||
|
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||||
|
id="copyBtn"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={copyIcon} /> Copy
|
||||||
|
</button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
|
||||||
|
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
||||||
|
Copied!
|
||||||
|
</Tooltip>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateShortUrlResult;
|
|
@ -1,40 +1,37 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { identity, pipe } from 'ramda';
|
import { identity, pipe } from 'ramda';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||||
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
|
import { ShortUrlModalProps } from '../data';
|
||||||
|
import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
|
||||||
|
|
||||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||||
|
|
||||||
const propTypes = {
|
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
||||||
shortUrl: shortUrlType,
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
toggle: PropTypes.func,
|
deleteShortUrl: (shortCode: string, domain: OptionalString) => Promise<void>;
|
||||||
isOpen: PropTypes.bool,
|
resetDeleteShortUrl: () => void;
|
||||||
shortUrlDeletion: shortUrlDeletionType,
|
}
|
||||||
deleteShortUrl: PropTypes.func,
|
|
||||||
resetDeleteShortUrl: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }) => {
|
const DeleteShortUrlModal = (
|
||||||
|
{ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }: DeleteShortUrlModalConnectProps,
|
||||||
|
) => {
|
||||||
const [ inputValue, setInputValue ] = useState('');
|
const [ inputValue, setInputValue ] = useState('');
|
||||||
|
|
||||||
useEffect(() => resetDeleteShortUrl, []);
|
useEffect(() => resetDeleteShortUrl, []);
|
||||||
|
|
||||||
const { error, errorData } = shortUrlDeletion;
|
const { error, errorData } = shortUrlDeletion;
|
||||||
const errorCode = error && (errorData.type || errorData.error);
|
const errorCode = error && (errorData?.type || errorData?.error);
|
||||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
||||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
||||||
const close = pipe(resetDeleteShortUrl, toggle);
|
const close = pipe(resetDeleteShortUrl, toggle);
|
||||||
const handleDeleteUrl = (e) => {
|
const handleDeleteUrl = handleEventPreventingDefault(() => {
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const { shortCode, domain } = shortUrl;
|
const { shortCode, domain } = shortUrl;
|
||||||
|
|
||||||
deleteShortUrl(shortCode, domain)
|
deleteShortUrl(shortCode, domain)
|
||||||
.then(toggle)
|
.then(toggle)
|
||||||
.catch(identity);
|
.catch(identity);
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={close} centered>
|
<Modal isOpen={isOpen} toggle={close} centered>
|
||||||
|
@ -56,8 +53,8 @@ const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, reset
|
||||||
|
|
||||||
{hasThresholdError && (
|
{hasThresholdError && (
|
||||||
<div className="p-2 mt-2 bg-warning text-center">
|
<div className="p-2 mt-2 bg-warning text-center">
|
||||||
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
{errorData?.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
||||||
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
{!errorData?.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasErrorOtherThanThreshold && (
|
{hasErrorOtherThanThreshold && (
|
||||||
|
@ -81,6 +78,4 @@ const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, reset
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
DeleteShortUrlModal.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default DeleteShortUrlModal;
|
export default DeleteShortUrlModal;
|
|
@ -1,41 +1,40 @@
|
||||||
import React, { useState } from 'react';
|
import React, { ChangeEvent, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { ShortUrlMetaEdition } from '../reducers/shortUrlMeta';
|
||||||
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
|
|
||||||
import DateInput from '../../utils/DateInput';
|
import DateInput from '../../utils/DateInput';
|
||||||
import { formatIsoDate } from '../../utils/helpers/date';
|
import { formatIsoDate } from '../../utils/helpers/date';
|
||||||
|
import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data';
|
||||||
|
import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils';
|
||||||
|
|
||||||
const propTypes = {
|
interface EditMetaModalConnectProps extends ShortUrlModalProps {
|
||||||
isOpen: PropTypes.bool.isRequired,
|
shortUrlMeta: ShortUrlMetaEdition;
|
||||||
toggle: PropTypes.func.isRequired,
|
resetShortUrlMeta: () => void;
|
||||||
shortUrl: shortUrlType.isRequired,
|
editShortUrlMeta: (shortCode: string, domain: OptionalString, meta: Nullable<ShortUrlMeta>) => Promise<void>;
|
||||||
shortUrlMeta: shortUrlEditMetaType,
|
}
|
||||||
editShortUrlMeta: PropTypes.func,
|
|
||||||
resetShortUrlMeta: PropTypes.func,
|
const dateOrUndefined = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'validUntil') => {
|
||||||
|
const date = shortUrl?.meta?.[dateName];
|
||||||
|
|
||||||
|
return date ? moment(date) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateOrUndefined = (shortUrl, dateName) => {
|
const EditMetaModal = (
|
||||||
const date = shortUrl && shortUrl.meta && shortUrl.meta[dateName];
|
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps,
|
||||||
|
) => {
|
||||||
return date && moment(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => {
|
|
||||||
const { saving, error } = shortUrlMeta;
|
const { saving, error } = shortUrlMeta;
|
||||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||||
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
||||||
const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil'));
|
const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil'));
|
||||||
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
|
const [ maxVisits, setMaxVisits ] = useState(shortUrl?.meta?.maxVisits);
|
||||||
|
|
||||||
const close = pipe(resetShortUrlMeta, toggle);
|
const close = pipe(resetShortUrlMeta, toggle);
|
||||||
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
|
const doEdit = async () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
|
||||||
maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null,
|
maxVisits: maxVisits && !isEmpty(maxVisits) ? maxVisits : null,
|
||||||
validSince: validSince && formatIsoDate(validSince),
|
validSince: validSince && formatIsoDate(validSince),
|
||||||
validUntil: validUntil && formatIsoDate(validUntil),
|
validUntil: validUntil && formatIsoDate(validUntil),
|
||||||
}).then(close);
|
}).then(close);
|
||||||
|
@ -49,7 +48,7 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet
|
||||||
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
|
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
|
<form onSubmit={handleEventPreventingDefault(doEdit)}>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<DateInput
|
<DateInput
|
||||||
|
@ -57,7 +56,7 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet
|
||||||
selected={validSince}
|
selected={validSince}
|
||||||
maxDate={validUntil}
|
maxDate={validUntil}
|
||||||
isClearable
|
isClearable
|
||||||
onChange={setValidSince}
|
onChange={setValidSince as any}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
|
@ -66,7 +65,7 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet
|
||||||
selected={validUntil}
|
selected={validUntil}
|
||||||
minDate={validSince}
|
minDate={validSince}
|
||||||
isClearable
|
isClearable
|
||||||
onChange={setValidUntil}
|
onChange={setValidUntil as any}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="mb-0">
|
<FormGroup className="mb-0">
|
||||||
|
@ -74,8 +73,8 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Maximum number of visits allowed"
|
placeholder="Maximum number of visits allowed"
|
||||||
min={1}
|
min={1}
|
||||||
value={maxVisits || ''}
|
value={maxVisits ?? ''}
|
||||||
onChange={(e) => setMaxVisits(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setMaxVisits(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
{error && (
|
{error && (
|
||||||
|
@ -93,6 +92,4 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
EditMetaModal.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default EditMetaModal;
|
export default EditMetaModal;
|
|
@ -1,32 +1,28 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
|
||||||
import { ShortUrlEditionType } from '../reducers/shortUrlEdition';
|
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
|
||||||
import { hasValue } from '../../utils/utils';
|
import { ShortUrlModalProps } from '../data';
|
||||||
|
|
||||||
const propTypes = {
|
interface EditShortUrlModalProps extends ShortUrlModalProps {
|
||||||
isOpen: PropTypes.bool.isRequired,
|
shortUrlEdition: ShortUrlEdition;
|
||||||
toggle: PropTypes.func.isRequired,
|
editShortUrl: (shortUrl: string, domain: OptionalString, longUrl: string) => Promise<void>;
|
||||||
shortUrl: shortUrlType.isRequired,
|
}
|
||||||
shortUrlEdition: ShortUrlEditionType,
|
|
||||||
editShortUrl: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }) => {
|
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => {
|
||||||
const { saving, error } = shortUrlEdition;
|
const { saving, error } = shortUrlEdition;
|
||||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
const url = shortUrl?.shortUrl ?? '';
|
||||||
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
|
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
|
||||||
|
|
||||||
const doEdit = () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
|
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
<ModalHeader toggle={toggle}>
|
<ModalHeader toggle={toggle}>
|
||||||
Edit long URL for <ExternalLink href={url} />
|
Edit long URL for <ExternalLink href={url} />
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
|
<form onSubmit={handleEventPreventingDefault(doEdit)}>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<FormGroup className="mb-0">
|
<FormGroup className="mb-0">
|
||||||
<Input
|
<Input
|
||||||
|
@ -52,6 +48,4 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
EditShortUrlModal.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default EditShortUrlModal;
|
export default EditShortUrlModal;
|
|
@ -1,56 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { ExternalLink } from 'react-external-link';
|
|
||||||
import { shortUrlTagsType } from '../reducers/shortUrlTags';
|
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
toggle: PropTypes.func.isRequired,
|
|
||||||
shortUrl: shortUrlType.isRequired,
|
|
||||||
shortUrlTags: shortUrlTagsType,
|
|
||||||
editShortUrlTags: PropTypes.func,
|
|
||||||
resetShortUrlsTags: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditTagsModal = (TagsSelector) => {
|
|
||||||
const EditTagsModalComp = ({ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }) => {
|
|
||||||
const [ selectedTags, setSelectedTags ] = useState(shortUrl.tags || []);
|
|
||||||
|
|
||||||
useEffect(() => resetShortUrlsTags, []);
|
|
||||||
|
|
||||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
|
||||||
const saveTags = () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
|
|
||||||
.then(toggle)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
|
||||||
<ModalHeader toggle={toggle}>
|
|
||||||
Edit tags for <ExternalLink href={url} />
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<TagsSelector tags={selectedTags} onChange={(tags) => setSelectedTags(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={saveTags}>
|
|
||||||
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
|
||||||
</button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
EditTagsModalComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return EditTagsModalComp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditTagsModal;
|
|
50
src/short-urls/helpers/EditTagsModal.tsx
Normal file
50
src/short-urls/helpers/EditTagsModal.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import React, { FC, useEffect, useState } from 'react';
|
||||||
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { ShortUrlTags } from '../reducers/shortUrlTags';
|
||||||
|
import { ShortUrlModalProps } from '../data';
|
||||||
|
import { OptionalString } from '../../utils/utils';
|
||||||
|
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
|
||||||
|
|
||||||
|
interface EditTagsModalProps extends ShortUrlModalProps {
|
||||||
|
shortUrlTags: ShortUrlTags;
|
||||||
|
editShortUrlTags: (shortCode: string, domain: OptionalString, tags: string[]) => Promise<void>;
|
||||||
|
resetShortUrlsTags: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
|
||||||
|
{ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }: EditTagsModalProps,
|
||||||
|
) => {
|
||||||
|
const [ selectedTags, setSelectedTags ] = useState<string[]>(shortUrl.tags || []);
|
||||||
|
|
||||||
|
useEffect(() => resetShortUrlsTags, []);
|
||||||
|
|
||||||
|
const url = shortUrl?.shortUrl ?? '';
|
||||||
|
const saveTags = async () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
|
||||||
|
.then(toggle)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
|
<ModalHeader toggle={toggle}>
|
||||||
|
Edit tags for <ExternalLink href={url} />
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<TagsSelector tags={selectedTags} onChange={setSelectedTags} />
|
||||||
|
{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={saveTags}>
|
||||||
|
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditTagsModal;
|
|
@ -1,29 +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 { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { ShortUrlModalProps } from '../data';
|
||||||
import './PreviewModal.scss';
|
import './PreviewModal.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const PreviewModal = ({ shortUrl: { shortUrl }, toggle, isOpen }: ShortUrlModalProps) => (
|
||||||
url: PropTypes.string,
|
|
||||||
toggle: PropTypes.func,
|
|
||||||
isOpen: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
const PreviewModal = ({ url, toggle, isOpen }) => (
|
|
||||||
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
||||||
<ModalHeader toggle={toggle}>
|
<ModalHeader toggle={toggle}>
|
||||||
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
Preview for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
||||||
</ModalHeader>
|
</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>
|
||||||
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
|
<img src={`${shortUrl}/preview`} className="preview-modal__img" alt="Preview" />
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
PreviewModal.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default PreviewModal;
|
export default PreviewModal;
|
|
@ -1,28 +1,20 @@
|
||||||
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 { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { ShortUrlModalProps } from '../data';
|
||||||
import './QrCodeModal.scss';
|
import './QrCodeModal.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen }: ShortUrlModalProps) => (
|
||||||
url: PropTypes.string,
|
|
||||||
toggle: PropTypes.func,
|
|
||||||
isOpen: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
const QrCodeModal = ({ url, toggle, isOpen }) => (
|
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
<ModalHeader toggle={toggle}>
|
<ModalHeader toggle={toggle}>
|
||||||
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
||||||
</ModalHeader>
|
</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={`${shortUrl}/qr-code`} className="qr-code-modal__img" alt="QR code" />
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
QrCodeModal.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default QrCodeModal;
|
export default QrCodeModal;
|
|
@ -1,24 +1,19 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { serverType } from '../../servers/prop-types';
|
|
||||||
import { prettify } from '../../utils/helpers/numbers';
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import VisitStatsLink, { VisitStatsLinkProps } from './VisitStatsLink';
|
||||||
import VisitStatsLink from './VisitStatsLink';
|
|
||||||
import './ShortUrlVisitsCount.scss';
|
import './ShortUrlVisitsCount.scss';
|
||||||
|
|
||||||
const propTypes = {
|
export interface ShortUrlVisitsCount extends VisitStatsLinkProps {
|
||||||
visitsCount: PropTypes.number.isRequired,
|
visitsCount: number;
|
||||||
shortUrl: shortUrlType,
|
active?: boolean;
|
||||||
selectedServer: serverType,
|
}
|
||||||
active: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }) => {
|
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCount) => {
|
||||||
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
|
const maxVisits = shortUrl?.meta?.maxVisits;
|
||||||
const visitsLink = (
|
const visitsLink = (
|
||||||
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
|
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
|
||||||
<strong
|
<strong
|
||||||
|
@ -34,7 +29,7 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = f
|
||||||
}
|
}
|
||||||
|
|
||||||
const prettifiedMaxVisits = prettify(maxVisits);
|
const prettifiedMaxVisits = prettify(maxVisits);
|
||||||
const tooltipRef = useRef();
|
const tooltipRef = useRef<HTMLElement | null>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
@ -52,13 +47,11 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = f
|
||||||
</sup>
|
</sup>
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<UncontrolledTooltip target={() => tooltipRef.current} placement="bottom">
|
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom">
|
||||||
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
|
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ShortUrlVisitsCount.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ShortUrlVisitsCount;
|
export default ShortUrlVisitsCount;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue