mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-24 09:58:21 +03:00
commit
6ed19af295
329 changed files with 25640 additions and 35212 deletions
20
.eslintrc
20
.eslintrc
|
@ -1,24 +1,16 @@
|
||||||
{
|
{
|
||||||
|
"root": true,
|
||||||
"extends": [
|
"extends": [
|
||||||
"@shlinkio/js-coding-standard"
|
"@shlinkio/js-coding-standard"
|
||||||
],
|
],
|
||||||
"plugins": ["jest"],
|
|
||||||
"env": {
|
|
||||||
"jest/globals": true
|
|
||||||
},
|
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"tsconfigRootDir": ".",
|
"project": "./tsconfig.json"
|
||||||
"createDefaultProgram": true
|
|
||||||
},
|
|
||||||
"globals": {
|
|
||||||
"process": true,
|
|
||||||
"setImmediate": true
|
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["src/service*.ts"],
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"complexity": "off",
|
"jsx-a11y/control-has-associated-label": "off",
|
||||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
"jsx-a11y/label-has-associated-control": "off",
|
||||||
"@typescript-eslint/no-unsafe-return": "off",
|
"jsx-a11y/click-events-have-key-events": "off",
|
||||||
"@typescript-eslint/no-unsafe-call": "off"
|
"jsx-a11y/no-static-element-interactions": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": [
|
"extends": [
|
||||||
"stylelint-config-adidas",
|
"@shlinkio/stylelint-config-css-coding-standard"
|
||||||
"stylelint-config-adidas-bem",
|
|
||||||
"stylelint-config-recommended-scss"
|
|
||||||
],
|
|
||||||
"syntax": "scss",
|
|
||||||
"plugins": [
|
|
||||||
"stylelint-scss"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
26
CHANGELOG.md
26
CHANGELOG.md
|
@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [3.7.0] - 2022-05-14
|
||||||
|
### Added
|
||||||
|
* [#622](https://github.com/shlinkio/shlink-web-client/pull/622) Added support to load domain visits when consuming Shlink 3.1.0 or newer.
|
||||||
|
* [#582](https://github.com/shlinkio/shlink-web-client/pull/582) Improved filtering short URLs by tag.
|
||||||
|
|
||||||
|
Now, a new full tags selector component is available, which allows selecting any of the existing tags and also composes a toggle to filter by "any" tag or "all" tags.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18.
|
||||||
|
* [#595](https://github.com/shlinkio/shlink-web-client/pull/595) Updated to react-chartjs-2 v4.1.0.
|
||||||
|
* [#594](https://github.com/shlinkio/shlink-web-client/pull/594) Updated to a new coding standard.
|
||||||
|
* [#627](https://github.com/shlinkio/shlink-web-client/pull/627) Updated to Jest 28.
|
||||||
|
* [#603](https://github.com/shlinkio/shlink-web-client/pull/603) Migrated to new and maintained dependencies to parse CSV<->JSON.
|
||||||
|
* [#610](https://github.com/shlinkio/shlink-web-client/pull/610) Migrated to a maintained coding style for CSS.
|
||||||
|
* [#619](https://github.com/shlinkio/shlink-web-client/pull/619) Introduced react testing library, to progressively replace enzyme.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* [#623](https://github.com/shlinkio/shlink-web-client/pull/623) Dropped support for Shlink older than 2.6.0.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [3.6.0] - 2022-03-17
|
## [3.6.0] - 2022-03-17
|
||||||
### Added
|
### Added
|
||||||
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
|
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
|
||||||
|
|
|
@ -2,7 +2,7 @@ FROM node:16.13-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
RUN cd /shlink-web-client && npm ci && npm run build
|
RUN cd /shlink-web-client && npm ci && NODE_ENV=production npm run build
|
||||||
|
|
||||||
FROM nginx:1.21-alpine
|
FROM nginx:1.21-alpine
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
|
|
|
@ -8,8 +8,4 @@ module.exports = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
plugins: [
|
|
||||||
'@babel/plugin-proposal-optional-chaining',
|
|
||||||
'@babel/plugin-proposal-nullish-coalescing-operator',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
101
config/env.js
101
config/env.js
|
@ -1,101 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const paths = require('./paths');
|
|
||||||
|
|
||||||
// Make sure that including paths.js after env.js will read .env variables.
|
|
||||||
delete require.cache[require.resolve('./paths')];
|
|
||||||
|
|
||||||
const { NODE_ENV } = process.env;
|
|
||||||
|
|
||||||
if (!NODE_ENV) {
|
|
||||||
throw new Error(
|
|
||||||
'The NODE_ENV environment variable is required but was not specified.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
|
|
||||||
const dotenvFiles = [
|
|
||||||
`${paths.dotenv}.${NODE_ENV}.local`,
|
|
||||||
`${paths.dotenv}.${NODE_ENV}`,
|
|
||||||
|
|
||||||
// Don't include `.env.local` for `test` environment
|
|
||||||
// since normally you expect tests to produce the same
|
|
||||||
// results for everyone
|
|
||||||
NODE_ENV !== 'test' && `${paths.dotenv}.local`,
|
|
||||||
paths.dotenv,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
// Load environment variables from .env* files. Suppress warnings using silent
|
|
||||||
// if this file is missing. dotenv will never modify any environment variables
|
|
||||||
// that have already been set. Variable expansion is supported in .env files.
|
|
||||||
// https://github.com/motdotla/dotenv
|
|
||||||
// https://github.com/motdotla/dotenv-expand
|
|
||||||
dotenvFiles.forEach((dotenvFile) => {
|
|
||||||
if (fs.existsSync(dotenvFile)) {
|
|
||||||
require('dotenv-expand')(
|
|
||||||
require('dotenv').config({
|
|
||||||
path: dotenvFile,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// We support resolving modules according to `NODE_PATH`.
|
|
||||||
// This lets you use absolute paths in imports inside large monorepos:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/253.
|
|
||||||
// It works similar to `NODE_PATH` in Node itself:
|
|
||||||
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
|
|
||||||
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
|
|
||||||
// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
|
|
||||||
// We also resolve them to make sure all tools using them work consistently.
|
|
||||||
const appDirectory = fs.realpathSync(process.cwd());
|
|
||||||
|
|
||||||
process.env.NODE_PATH = (process.env.NODE_PATH || '')
|
|
||||||
.split(path.delimiter)
|
|
||||||
.filter((folder) => folder && !path.isAbsolute(folder))
|
|
||||||
.map((folder) => path.resolve(appDirectory, folder))
|
|
||||||
.join(path.delimiter);
|
|
||||||
|
|
||||||
// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
|
|
||||||
// injected into the application via DefinePlugin in Webpack configuration.
|
|
||||||
const REACT_APP = /^REACT_APP_/i;
|
|
||||||
|
|
||||||
function getClientEnvironment(publicUrl) {
|
|
||||||
const raw = Object.keys(process.env)
|
|
||||||
.filter((key) => REACT_APP.test(key))
|
|
||||||
.reduce(
|
|
||||||
(env, key) => {
|
|
||||||
env[key] = process.env[key];
|
|
||||||
|
|
||||||
return env;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
|
|
||||||
// Useful for determining whether we’re running in production mode.
|
|
||||||
// Most importantly, it switches React into the correct mode.
|
|
||||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
|
||||||
|
|
||||||
// Useful for resolving the correct path to static assets in `public`.
|
|
||||||
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
|
|
||||||
// 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.
|
|
||||||
PUBLIC_URL: publicUrl,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stringify all values so we can feed into Webpack DefinePlugin
|
|
||||||
const stringified = {
|
|
||||||
'process.env': Object.keys(raw).reduce((env, key) => {
|
|
||||||
env[key] = JSON.stringify(raw[key]);
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}, {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return { raw, stringified };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = getClientEnvironment;
|
|
12
config/jest/cssTransform.js
Normal file
12
config/jest/cssTransform.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
// This is a custom Jest transformer turning style imports into empty objects.
|
||||||
|
// http://facebook.github.io/jest/docs/en/webpack.html
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
process() {
|
||||||
|
return { code: 'module.exports = {};' };
|
||||||
|
},
|
||||||
|
getCacheKey() {
|
||||||
|
// The output is always the same.
|
||||||
|
return 'cssTransform';
|
||||||
|
},
|
||||||
|
};
|
|
@ -24,6 +24,8 @@ module.exports = {
|
||||||
};`;
|
};`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `module.exports = ${assetFilename};`;
|
return {
|
||||||
|
code: `module.exports = ${assetFilename};`
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
4
config/jest/setupBeforeEnzyme.js
Normal file
4
config/jest/setupBeforeEnzyme.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import * as util from 'util';
|
||||||
|
|
||||||
|
global.TextEncoder = util.TextEncoder;
|
||||||
|
global.TextDecoder = util.TextDecoder;
|
7
config/jest/setupTests.ts
Normal file
7
config/jest/setupTests.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import 'jest-canvas-mock';
|
||||||
|
import ResizeObserver from 'resize-observer-polyfill';
|
||||||
|
|
||||||
|
(global as any).ResizeObserver = ResizeObserver;
|
||||||
|
(global as any).scrollTo = () => {};
|
||||||
|
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
|
@ -1,89 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const url = require('url');
|
|
||||||
|
|
||||||
// Make sure any symlinks in the project folder are resolved:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/637
|
|
||||||
const appDirectory = fs.realpathSync(process.cwd());
|
|
||||||
const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);
|
|
||||||
|
|
||||||
const envPublicUrl = process.env.PUBLIC_URL;
|
|
||||||
|
|
||||||
function ensureSlash(inputPath, needsSlash) {
|
|
||||||
const hasSlash = inputPath.endsWith('/');
|
|
||||||
|
|
||||||
if (hasSlash && !needsSlash) {
|
|
||||||
return inputPath.substr(0, inputPath.length - 1);
|
|
||||||
} else if (!hasSlash && needsSlash) {
|
|
||||||
return `${inputPath}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPublicUrl = (appPackageJson) =>
|
|
||||||
envPublicUrl || require(appPackageJson).homepage;
|
|
||||||
|
|
||||||
// We use `PUBLIC_URL` environment variable or "homepage" field to infer
|
|
||||||
// "public path" at which the app is served.
|
|
||||||
// Webpack needs to know it to put the right <script> hrefs into HTML even in
|
|
||||||
// single-page apps that may serve index.html for nested URLs like /todos/42.
|
|
||||||
// We can't use a relative path in HTML because we don't want to load something
|
|
||||||
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
|
|
||||||
function getServedPath(appPackageJson) {
|
|
||||||
const publicUrl = getPublicUrl(appPackageJson);
|
|
||||||
const servedUrl =
|
|
||||||
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
|
|
||||||
|
|
||||||
return ensureSlash(servedUrl, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleFileExtensions = [
|
|
||||||
'web.mjs',
|
|
||||||
'mjs',
|
|
||||||
'web.js',
|
|
||||||
'js',
|
|
||||||
'web.ts',
|
|
||||||
'ts',
|
|
||||||
'web.tsx',
|
|
||||||
'tsx',
|
|
||||||
'json',
|
|
||||||
'web.jsx',
|
|
||||||
'jsx',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Resolve file paths in the same order as webpack
|
|
||||||
const resolveModule = (resolveFn, filePath) => {
|
|
||||||
const extension = moduleFileExtensions.find((extension) =>
|
|
||||||
fs.existsSync(resolveFn(`${filePath}.${extension}`)));
|
|
||||||
|
|
||||||
if (extension) {
|
|
||||||
return resolveFn(`${filePath}.${extension}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveFn(`${filePath}.js`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// config after eject: we're in ./config/
|
|
||||||
module.exports = {
|
|
||||||
dotenv: resolveApp('.env'),
|
|
||||||
appPath: resolveApp('.'),
|
|
||||||
appBuild: resolveApp('build'),
|
|
||||||
appPublic: resolveApp('public'),
|
|
||||||
appHtml: resolveApp('public/index.html'),
|
|
||||||
appIndexJs: resolveModule(resolveApp, 'src/index'),
|
|
||||||
appPackageJson: resolveApp('package.json'),
|
|
||||||
appSrc: resolveApp('src'),
|
|
||||||
appTsConfig: resolveApp('tsconfig.json'),
|
|
||||||
yarnLockFile: resolveApp('yarn.lock'),
|
|
||||||
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
|
|
||||||
proxySetup: resolveApp('src/setupProxy.js'),
|
|
||||||
appNodeModules: resolveApp('node_modules'),
|
|
||||||
publicUrl: getPublicUrl(resolveApp('package.json')),
|
|
||||||
servedPath: getServedPath(resolveApp('package.json')),
|
|
||||||
swSrc: resolveModule(resolveApp, 'src/service-worker'),
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports.moduleFileExtensions = moduleFileExtensions;
|
|
|
@ -1,674 +0,0 @@
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const resolve = require('resolve');
|
|
||||||
const PnpWebpackPlugin = require('pnp-webpack-plugin');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
||||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
|
|
||||||
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
|
|
||||||
const TerserPlugin = require('terser-webpack-plugin');
|
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|
||||||
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
|
||||||
const safePostCssParser = require('postcss-safe-parser');
|
|
||||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
|
||||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
|
||||||
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
|
||||||
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
|
||||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
|
||||||
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
|
||||||
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
|
|
||||||
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt');
|
|
||||||
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
|
|
||||||
const getClientEnvironment = require('./env');
|
|
||||||
const paths = require('./paths');
|
|
||||||
|
|
||||||
// Source maps are resource heavy and can cause out of memory issue for large source files.
|
|
||||||
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
|
|
||||||
|
|
||||||
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
|
|
||||||
// makes for a smoother build process.
|
|
||||||
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
|
|
||||||
|
|
||||||
// Check if TypeScript is setup
|
|
||||||
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
|
||||||
|
|
||||||
// Get the path to the uncompiled service worker (if it exists).
|
|
||||||
const swSrc = paths.swSrc;
|
|
||||||
|
|
||||||
// style files regexes
|
|
||||||
const cssRegex = /\.css$/;
|
|
||||||
const cssModuleRegex = /\.module\.css$/;
|
|
||||||
const sassRegex = /\.(scss|sass)$/;
|
|
||||||
const sassModuleRegex = /\.module\.(scss|sass)$/;
|
|
||||||
|
|
||||||
// This is the production and development configuration.
|
|
||||||
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
|
|
||||||
/* eslint-disable complexity */
|
|
||||||
module.exports = (webpackEnv) => {
|
|
||||||
const isEnvDevelopment = webpackEnv === 'development';
|
|
||||||
const isEnvProduction = webpackEnv === 'production';
|
|
||||||
|
|
||||||
// Webpack uses `publicPath` to determine where the app is being served from.
|
|
||||||
// It requires a trailing slash, or the file assets will get an incorrect path.
|
|
||||||
// In development, we always serve from the root. This makes config easier.
|
|
||||||
const publicPath = isEnvProduction
|
|
||||||
? paths.servedPath
|
|
||||||
: isEnvDevelopment && '/';
|
|
||||||
|
|
||||||
// Some apps do not use client-side routing with pushState.
|
|
||||||
// For these, "homepage" can be set to "." to enable relative asset paths.
|
|
||||||
const shouldUseRelativeAssetPaths = publicPath === './';
|
|
||||||
|
|
||||||
// `publicUrl` is just like `publicPath`, but we will provide it to our app
|
|
||||||
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
|
|
||||||
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
|
|
||||||
const publicUrl = isEnvProduction
|
|
||||||
? publicPath.slice(0, -1)
|
|
||||||
: isEnvDevelopment && '';
|
|
||||||
|
|
||||||
// Get environment variables to inject into our app.
|
|
||||||
const env = getClientEnvironment(publicUrl);
|
|
||||||
|
|
||||||
// common function to get style loaders
|
|
||||||
const getStyleLoaders = (cssOptions, preProcessor) => {
|
|
||||||
const loaders = [
|
|
||||||
isEnvDevelopment && require.resolve('style-loader'),
|
|
||||||
isEnvProduction && {
|
|
||||||
loader: MiniCssExtractPlugin.loader,
|
|
||||||
options: Object.assign(
|
|
||||||
{},
|
|
||||||
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loader: require.resolve('css-loader'),
|
|
||||||
options: cssOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
|
|
||||||
// Options for PostCSS as we reference these options twice
|
|
||||||
// Adds vendor prefixing based on your specified browser support in
|
|
||||||
// package.json
|
|
||||||
loader: require.resolve('postcss-loader'),
|
|
||||||
options: {
|
|
||||||
|
|
||||||
// Necessary for external CSS imports to work
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2677
|
|
||||||
ident: 'postcss',
|
|
||||||
plugins: () => [
|
|
||||||
require('postcss-flexbugs-fixes'),
|
|
||||||
require('postcss-preset-env')({
|
|
||||||
autoprefixer: {
|
|
||||||
flexbox: 'no-2009',
|
|
||||||
},
|
|
||||||
stage: 3,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
if (preProcessor) {
|
|
||||||
loaders.push({
|
|
||||||
loader: require.resolve(preProcessor),
|
|
||||||
options: {
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return loaders;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
|
|
||||||
|
|
||||||
// Stop compilation early in production
|
|
||||||
bail: isEnvProduction,
|
|
||||||
devtool: isEnvProduction
|
|
||||||
? shouldUseSourceMap
|
|
||||||
? 'source-map'
|
|
||||||
: false
|
|
||||||
: isEnvDevelopment && 'cheap-module-source-map',
|
|
||||||
|
|
||||||
// These are the "entry points" to our application.
|
|
||||||
// This means they will be the "root" imports that are included in JS bundle.
|
|
||||||
entry: [
|
|
||||||
|
|
||||||
// Include an alternative client for WebpackDevServer. A client's job is to
|
|
||||||
// connect to WebpackDevServer by a socket and get notified about changes.
|
|
||||||
// When you save a file, the client will either apply hot updates (in case
|
|
||||||
// of CSS changes), or refresh the page (in case of JS changes). When you
|
|
||||||
// make a syntax error, this client will display a syntax error overlay.
|
|
||||||
// Note: instead of the default WebpackDevServer client, we use a custom one
|
|
||||||
// to bring better experience for Create React App users. You can replace
|
|
||||||
// the line below with these two lines if you prefer the stock client:
|
|
||||||
// require.resolve('webpack-dev-server/client') + '?/',
|
|
||||||
// require.resolve('webpack/hot/dev-server'),
|
|
||||||
isEnvDevelopment &&
|
|
||||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
|
||||||
|
|
||||||
// Finally, this is your app's code:
|
|
||||||
paths.appIndexJs,
|
|
||||||
|
|
||||||
// We include the app code last so that if there is a runtime error during
|
|
||||||
// initialization, it doesn't blow up the WebpackDevServer client, and
|
|
||||||
// changing JS code would still trigger a refresh.
|
|
||||||
].filter(Boolean),
|
|
||||||
output: {
|
|
||||||
|
|
||||||
// The build folder.
|
|
||||||
path: isEnvProduction ? paths.appBuild : undefined,
|
|
||||||
|
|
||||||
// Add /* filename */ comments to generated require()s in the output.
|
|
||||||
pathinfo: isEnvDevelopment,
|
|
||||||
|
|
||||||
// There will be one main bundle, and one file per asynchronous chunk.
|
|
||||||
// In development, it does not produce real files.
|
|
||||||
filename: isEnvProduction
|
|
||||||
? 'static/js/[name].[chunkhash:8].js'
|
|
||||||
: isEnvDevelopment && 'static/js/bundle.js',
|
|
||||||
|
|
||||||
// There are also additional JS chunk files if you use code splitting.
|
|
||||||
chunkFilename: isEnvProduction
|
|
||||||
? 'static/js/[name].[chunkhash:8].chunk.js'
|
|
||||||
: isEnvDevelopment && 'static/js/[name].chunk.js',
|
|
||||||
|
|
||||||
// We inferred the "public path" (such as / or /my-project) from homepage.
|
|
||||||
// We use "/" in development.
|
|
||||||
publicPath,
|
|
||||||
|
|
||||||
// Point sourcemap entries to original disk location (format as URL on Windows)
|
|
||||||
devtoolModuleFilenameTemplate: isEnvProduction
|
|
||||||
? (info) =>
|
|
||||||
path
|
|
||||||
.relative(paths.appSrc, info.absoluteResourcePath)
|
|
||||||
.replace(/\\/g, '/')
|
|
||||||
: isEnvDevelopment &&
|
|
||||||
((info) => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
|
|
||||||
},
|
|
||||||
optimization: {
|
|
||||||
minimize: isEnvProduction,
|
|
||||||
minimizer: [
|
|
||||||
|
|
||||||
// This is only used in production mode
|
|
||||||
new TerserPlugin({
|
|
||||||
terserOptions: {
|
|
||||||
parse: {
|
|
||||||
|
|
||||||
// we want terser to parse ecma 8 code. However, we don't want it
|
|
||||||
// to apply any minfication steps that turns valid ecma 5 code
|
|
||||||
// into invalid ecma 5 code. This is why the 'compress' and 'output'
|
|
||||||
// sections only apply transformations that are ecma 5 safe
|
|
||||||
// https://github.com/facebook/create-react-app/pull/4234
|
|
||||||
ecma: 8,
|
|
||||||
},
|
|
||||||
compress: {
|
|
||||||
ecma: 5,
|
|
||||||
warnings: false,
|
|
||||||
|
|
||||||
// Disabled because of an issue with Uglify breaking seemingly valid code:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2376
|
|
||||||
// Pending further investigation:
|
|
||||||
// https://github.com/mishoo/UglifyJS2/issues/2011
|
|
||||||
comparisons: false,
|
|
||||||
|
|
||||||
// Disabled because of an issue with Terser breaking valid code:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/5250
|
|
||||||
// Pending futher investigation:
|
|
||||||
// https://github.com/terser-js/terser/issues/120
|
|
||||||
inline: 2,
|
|
||||||
},
|
|
||||||
mangle: {
|
|
||||||
safari10: true,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
ecma: 5,
|
|
||||||
comments: false,
|
|
||||||
|
|
||||||
// Turned on because emoji and regex is not minified properly using default
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2488
|
|
||||||
ascii_only: true, // eslint-disable-line @typescript-eslint/camelcase
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Use multi-process parallel running to improve the build speed
|
|
||||||
// Default number of concurrent runs: os.cpus().length - 1
|
|
||||||
parallel: true,
|
|
||||||
|
|
||||||
// Enable file caching
|
|
||||||
cache: true,
|
|
||||||
sourceMap: shouldUseSourceMap,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// This is only used in production mode
|
|
||||||
new OptimizeCSSAssetsPlugin({
|
|
||||||
cssProcessorOptions: {
|
|
||||||
parser: safePostCssParser,
|
|
||||||
map: shouldUseSourceMap
|
|
||||||
? {
|
|
||||||
|
|
||||||
// `inline: false` forces the sourcemap to be output into a
|
|
||||||
// separate file
|
|
||||||
inline: false,
|
|
||||||
|
|
||||||
// `annotation: true` appends the sourceMappingURL to the end of
|
|
||||||
// the css file, helping the browser find the sourcemap
|
|
||||||
annotation: true,
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Automatically split vendor and commons
|
|
||||||
// https://twitter.com/wSokra/status/969633336732905474
|
|
||||||
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
|
|
||||||
splitChunks: {
|
|
||||||
chunks: 'all',
|
|
||||||
name: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Keep the runtime chunk separated to enable long term caching
|
|
||||||
// https://twitter.com/wSokra/status/969679223278505985
|
|
||||||
runtimeChunk: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
|
|
||||||
// This allows you to set a fallback for where Webpack should look for modules.
|
|
||||||
// We placed these paths second because we want `node_modules` to "win"
|
|
||||||
// if there are any conflicts. This matches Node resolution mechanism.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/253
|
|
||||||
modules: [ 'node_modules' ].concat(
|
|
||||||
|
|
||||||
// It is guaranteed to exist because we tweak it in `env.js`
|
|
||||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean),
|
|
||||||
),
|
|
||||||
|
|
||||||
// These are the reasonable defaults supported by the Node ecosystem.
|
|
||||||
// We also include JSX as a common component filename extension to support
|
|
||||||
// some tools, although we do not recommend using it, see:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/290
|
|
||||||
// `web` extension prefixes have been added for better support
|
|
||||||
// for React Native Web.
|
|
||||||
extensions: paths.moduleFileExtensions
|
|
||||||
.map((ext) => `.${ext}`)
|
|
||||||
.filter((ext) => useTypeScript || !ext.includes('ts')),
|
|
||||||
alias: {
|
|
||||||
|
|
||||||
// Support React Native Web
|
|
||||||
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
|
|
||||||
'react-native': 'react-native-web',
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
|
|
||||||
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
|
|
||||||
// guards against forgotten dependencies and such.
|
|
||||||
PnpWebpackPlugin,
|
|
||||||
|
|
||||||
// Prevents users from importing files from outside of src/ (or node_modules/).
|
|
||||||
// This often causes confusion because we only process files within src/ with babel.
|
|
||||||
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
|
|
||||||
// please link the files into your node_modules/ and let module-resolution kick in.
|
|
||||||
// Make sure your source files are compiled, as they will not be processed in any way.
|
|
||||||
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
resolveLoader: {
|
|
||||||
plugins: [
|
|
||||||
|
|
||||||
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
|
|
||||||
// from the current package.
|
|
||||||
PnpWebpackPlugin.moduleLoader(module),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
strictExportPresence: true,
|
|
||||||
rules: [
|
|
||||||
|
|
||||||
// Disable require.ensure as it's not a standard language feature.
|
|
||||||
{ parser: { requireEnsure: false } },
|
|
||||||
|
|
||||||
// First, run the linter.
|
|
||||||
// It's important to do this before Babel processes the JS.
|
|
||||||
{
|
|
||||||
test: /\.(js|mjs|jsx)$/,
|
|
||||||
enforce: 'pre',
|
|
||||||
use: [
|
|
||||||
{
|
|
||||||
options: {
|
|
||||||
formatter: require.resolve('react-dev-utils/eslintFormatter'),
|
|
||||||
eslintPath: require.resolve('eslint'),
|
|
||||||
|
|
||||||
},
|
|
||||||
loader: require.resolve('eslint-loader'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
include: paths.appSrc,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
|
|
||||||
// "oneOf" will traverse all following loaders until one will
|
|
||||||
// match the requirements. When no loader matches it will fall
|
|
||||||
// back to the "file" loader at the end of the loader list.
|
|
||||||
oneOf: [
|
|
||||||
|
|
||||||
// "url" loader works like "file" loader except that it embeds assets
|
|
||||||
// smaller than specified limit in bytes as data URLs to avoid requests.
|
|
||||||
// A missing `test` is equivalent to a match.
|
|
||||||
{
|
|
||||||
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
|
|
||||||
loader: require.resolve('url-loader'),
|
|
||||||
options: {
|
|
||||||
limit: 10000,
|
|
||||||
name: 'static/media/[name].[hash:8].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Process application JS with Babel.
|
|
||||||
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
|
|
||||||
{
|
|
||||||
test: /\.(js|mjs|jsx|ts|tsx)$/,
|
|
||||||
include: paths.appSrc,
|
|
||||||
loader: require.resolve('babel-loader'),
|
|
||||||
options: {
|
|
||||||
customize: require.resolve(
|
|
||||||
'babel-preset-react-app/webpack-overrides',
|
|
||||||
),
|
|
||||||
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
require.resolve('babel-plugin-named-asset-import'),
|
|
||||||
{
|
|
||||||
loaderMap: {
|
|
||||||
svg: {
|
|
||||||
ReactComponent:
|
|
||||||
'@svgr/webpack?-prettier,-svgo![path]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
// This is a feature of `babel-loader` for webpack (not Babel itself).
|
|
||||||
// It enables caching results in ./node_modules/.cache/babel-loader/
|
|
||||||
// directory for faster rebuilds.
|
|
||||||
cacheDirectory: true,
|
|
||||||
cacheCompression: isEnvProduction,
|
|
||||||
compact: isEnvProduction,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Process any JS outside of the app with Babel.
|
|
||||||
// Unlike the application JS, we only compile the standard ES features.
|
|
||||||
{
|
|
||||||
test: /\.(js|mjs)$/,
|
|
||||||
exclude: /@babel(?:\/|\\{1,2})runtime/,
|
|
||||||
loader: require.resolve('babel-loader'),
|
|
||||||
options: {
|
|
||||||
babelrc: false,
|
|
||||||
configFile: false,
|
|
||||||
compact: false,
|
|
||||||
presets: [
|
|
||||||
[
|
|
||||||
require.resolve('babel-preset-react-app/dependencies'),
|
|
||||||
{ helpers: true },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
cacheDirectory: true,
|
|
||||||
cacheCompression: isEnvProduction,
|
|
||||||
|
|
||||||
// If an error happens in a package, it's possible to be
|
|
||||||
// because it was compiled. Thus, we don't want the browser
|
|
||||||
// debugger to show the original code. Instead, the code
|
|
||||||
// being evaluated would be much more helpful.
|
|
||||||
sourceMaps: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// "postcss" loader applies autoprefixer to our CSS.
|
|
||||||
// "css" loader resolves paths in CSS and adds assets as dependencies.
|
|
||||||
// "style" loader turns CSS into JS modules that inject <style> tags.
|
|
||||||
// In production, we use MiniCSSExtractPlugin to extract that CSS
|
|
||||||
// to a file, but in development "style" loader enables hot editing
|
|
||||||
// of CSS.
|
|
||||||
// By default we support CSS Modules with the extension .module.css
|
|
||||||
{
|
|
||||||
test: cssRegex,
|
|
||||||
exclude: cssModuleRegex,
|
|
||||||
use: getStyleLoaders({
|
|
||||||
importLoaders: 1,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Don't consider CSS imports dead code even if the
|
|
||||||
// containing package claims to have no side effects.
|
|
||||||
// Remove this when webpack adds a warning or an error for this.
|
|
||||||
// See https://github.com/webpack/webpack/issues/6571
|
|
||||||
sideEffects: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
|
|
||||||
// using the extension .module.css
|
|
||||||
{
|
|
||||||
test: cssModuleRegex,
|
|
||||||
use: getStyleLoaders({
|
|
||||||
importLoaders: 1,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
modules: true,
|
|
||||||
getLocalIdent: getCSSModuleLocalIdent,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Opt-in support for SASS (using .scss or .sass extensions).
|
|
||||||
// By default we support SASS Modules with the
|
|
||||||
// extensions .module.scss or .module.sass
|
|
||||||
{
|
|
||||||
test: sassRegex,
|
|
||||||
exclude: sassModuleRegex,
|
|
||||||
use: getStyleLoaders(
|
|
||||||
{
|
|
||||||
importLoaders: 2,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
},
|
|
||||||
'sass-loader',
|
|
||||||
),
|
|
||||||
|
|
||||||
// Don't consider CSS imports dead code even if the
|
|
||||||
// containing package claims to have no side effects.
|
|
||||||
// Remove this when webpack adds a warning or an error for this.
|
|
||||||
// See https://github.com/webpack/webpack/issues/6571
|
|
||||||
sideEffects: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Adds support for CSS Modules, but using SASS
|
|
||||||
// using the extension .module.scss or .module.sass
|
|
||||||
{
|
|
||||||
test: sassModuleRegex,
|
|
||||||
use: getStyleLoaders(
|
|
||||||
{
|
|
||||||
importLoaders: 2,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
modules: true,
|
|
||||||
getLocalIdent: getCSSModuleLocalIdent,
|
|
||||||
},
|
|
||||||
'sass-loader',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
// "file" loader makes sure those assets get served by WebpackDevServer.
|
|
||||||
// When you `import` an asset, you get its (virtual) filename.
|
|
||||||
// In production, they would get copied to the `build` folder.
|
|
||||||
// This loader doesn't use a "test" so it will catch all modules
|
|
||||||
// that fall through the other loaders.
|
|
||||||
{
|
|
||||||
loader: require.resolve('file-loader'),
|
|
||||||
|
|
||||||
// Exclude `js` files to keep "css" loader working as it injects
|
|
||||||
// its runtime that would otherwise be processed through "file" loader.
|
|
||||||
// Also exclude `html` and `json` extensions so they get processed
|
|
||||||
// by webpacks internal loaders.
|
|
||||||
exclude: [ /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/ ],
|
|
||||||
options: {
|
|
||||||
name: 'static/media/[name].[hash:8].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ** STOP ** Are you adding a new loader?
|
|
||||||
// Make sure to add the new loader(s) before the "file" loader.
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
|
|
||||||
// Generates an `index.html` file with the <script> injected.
|
|
||||||
new HtmlWebpackPlugin(
|
|
||||||
Object.assign(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
inject: true,
|
|
||||||
template: paths.appHtml,
|
|
||||||
},
|
|
||||||
isEnvProduction
|
|
||||||
? {
|
|
||||||
minify: {
|
|
||||||
removeComments: true,
|
|
||||||
collapseWhitespace: true,
|
|
||||||
removeRedundantAttributes: true,
|
|
||||||
useShortDoctype: true,
|
|
||||||
removeEmptyAttributes: true,
|
|
||||||
removeStyleLinkTypeAttributes: true,
|
|
||||||
keepClosingSlash: true,
|
|
||||||
minifyJS: true,
|
|
||||||
minifyCSS: true,
|
|
||||||
minifyURLs: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Inlines the webpack runtime script. This script is too small to warrant
|
|
||||||
// a network request.
|
|
||||||
isEnvProduction &&
|
|
||||||
shouldInlineRuntimeChunk &&
|
|
||||||
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [ /runtime~.+[.]js/ ]),
|
|
||||||
|
|
||||||
// Makes some environment variables available in index.html.
|
|
||||||
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
|
|
||||||
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
|
||||||
// In production, it will be an empty string unless you specify "homepage"
|
|
||||||
// in `package.json`, in which case it will be the pathname of that URL.
|
|
||||||
// In development, this will be an empty string.
|
|
||||||
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
|
|
||||||
|
|
||||||
// This gives some necessary context to module not found errors, such as
|
|
||||||
// the requesting resource.
|
|
||||||
new ModuleNotFoundPlugin(paths.appPath),
|
|
||||||
|
|
||||||
// Makes some environment variables available to the JS code, for example:
|
|
||||||
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
|
|
||||||
// It is absolutely essential that NODE_ENV is set to production
|
|
||||||
// during a production build.
|
|
||||||
// Otherwise React will be compiled in the very slow development mode.
|
|
||||||
new webpack.DefinePlugin(env.stringified),
|
|
||||||
|
|
||||||
// This is necessary to emit hot updates (currently CSS only):
|
|
||||||
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
|
|
||||||
|
|
||||||
// Watcher doesn't work well if you mistype casing in a path so we use
|
|
||||||
// a plugin that prints an error when you attempt to do this.
|
|
||||||
// See https://github.com/facebook/create-react-app/issues/240
|
|
||||||
isEnvDevelopment && new CaseSensitivePathsPlugin(),
|
|
||||||
|
|
||||||
// If you require a missing module and then `npm install` it, you still have
|
|
||||||
// to restart the development server for Webpack to discover it. This plugin
|
|
||||||
// makes the discovery automatic so you don't have to restart.
|
|
||||||
// See https://github.com/facebook/create-react-app/issues/186
|
|
||||||
isEnvDevelopment &&
|
|
||||||
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
|
|
||||||
isEnvProduction &&
|
|
||||||
new MiniCssExtractPlugin({
|
|
||||||
|
|
||||||
// Options similar to the same options in webpackOptions.output
|
|
||||||
// both options are optional
|
|
||||||
filename: 'static/css/[name].[contenthash:8].css',
|
|
||||||
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Generate a manifest file which contains a mapping of all asset filenames
|
|
||||||
// to their corresponding output file so that tools can pick it up without
|
|
||||||
// having to parse `index.html`.
|
|
||||||
new ManifestPlugin({
|
|
||||||
fileName: 'asset-manifest.json',
|
|
||||||
publicPath,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Moment.js is an extremely popular library that bundles large locale files
|
|
||||||
// by default due to how Webpack interprets its code. This is a practical
|
|
||||||
// solution that requires the user to opt into importing specific locales.
|
|
||||||
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
|
|
||||||
// You can remove this if you don't use Moment.js:
|
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
|
||||||
|
|
||||||
// Generate a service worker script that will precache, and keep up to date,
|
|
||||||
// the HTML & assets that are part of the webpack build.
|
|
||||||
isEnvProduction && fs.existsSync(swSrc) && new WorkboxWebpackPlugin.InjectManifest({
|
|
||||||
swSrc,
|
|
||||||
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
|
|
||||||
exclude: [ /\.map$/, /asset-manifest\.json$/, /LICENSE/ ],
|
|
||||||
// Bump up the default maximum size (2mb) that's precached,
|
|
||||||
// to make lazy-loading failure scenarios less likely.
|
|
||||||
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
|
|
||||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// TypeScript type checking
|
|
||||||
useTypeScript &&
|
|
||||||
new ForkTsCheckerWebpackPlugin({
|
|
||||||
typescript: resolve.sync('typescript', {
|
|
||||||
basedir: paths.appNodeModules,
|
|
||||||
}),
|
|
||||||
async: false,
|
|
||||||
checkSyntacticErrors: true,
|
|
||||||
tsconfig: paths.appTsConfig,
|
|
||||||
compilerOptions: {
|
|
||||||
module: 'esnext',
|
|
||||||
moduleResolution: 'node',
|
|
||||||
resolveJsonModule: true,
|
|
||||||
isolatedModules: true,
|
|
||||||
noEmit: true,
|
|
||||||
jsx: 'preserve',
|
|
||||||
},
|
|
||||||
reportFiles: [
|
|
||||||
'**',
|
|
||||||
'!**/*.json',
|
|
||||||
'!**/__tests__/**',
|
|
||||||
'!**/?(*.)(spec|test).*',
|
|
||||||
'!**/src/setupProxy.*',
|
|
||||||
'!**/src/setupTests.*',
|
|
||||||
],
|
|
||||||
watch: paths.appSrc,
|
|
||||||
silent: true,
|
|
||||||
formatter: typescriptFormatter,
|
|
||||||
}),
|
|
||||||
].filter(Boolean),
|
|
||||||
|
|
||||||
// Some libraries import Node modules but don't use them in the browser.
|
|
||||||
// Tell Webpack to provide empty mocks for them so importing them works.
|
|
||||||
node: {
|
|
||||||
dgram: 'empty',
|
|
||||||
fs: 'empty',
|
|
||||||
net: 'empty',
|
|
||||||
tls: 'empty',
|
|
||||||
child_process: 'empty', // eslint-disable-line @typescript-eslint/camelcase
|
|
||||||
},
|
|
||||||
|
|
||||||
// Turn off performance processing because we utilize
|
|
||||||
// our own hints via the FileSizeReporter
|
|
||||||
performance: false,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,116 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
|
|
||||||
const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware');
|
|
||||||
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
|
|
||||||
const ignoredFiles = require('react-dev-utils/ignoredFiles');
|
|
||||||
const paths = require('./paths');
|
|
||||||
|
|
||||||
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
|
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
|
||||||
|
|
||||||
module.exports = function(proxy, allowedHost) {
|
|
||||||
return {
|
|
||||||
|
|
||||||
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
|
|
||||||
// websites from potentially accessing local content through DNS rebinding:
|
|
||||||
// https://github.com/webpack/webpack-dev-server/issues/887
|
|
||||||
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
|
|
||||||
// However, it made several existing use cases such as development in cloud
|
|
||||||
// environment or subdomains in development significantly more complicated:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2271
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2233
|
|
||||||
// While we're investigating better solutions, for now we will take a
|
|
||||||
// compromise. Since our WDS configuration only serves files in the `public`
|
|
||||||
// folder we won't consider accessing them a vulnerability. However, if you
|
|
||||||
// use the `proxy` feature, it gets more dangerous because it can expose
|
|
||||||
// remote code execution vulnerabilities in backends like Django and Rails.
|
|
||||||
// So we will disable the host check normally, but enable it if you have
|
|
||||||
// specified the `proxy` setting. Finally, we let you override it if you
|
|
||||||
// really know what you're doing with a special environment variable.
|
|
||||||
disableHostCheck:
|
|
||||||
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true',
|
|
||||||
|
|
||||||
// Enable gzip compression of generated files.
|
|
||||||
compress: true,
|
|
||||||
|
|
||||||
// Silence WebpackDevServer's own logs since they're generally not useful.
|
|
||||||
// It will still show compile warnings and errors with this setting.
|
|
||||||
clientLogLevel: 'none',
|
|
||||||
|
|
||||||
// By default WebpackDevServer serves physical files from current directory
|
|
||||||
// in addition to all the virtual build products that it serves from memory.
|
|
||||||
// This is confusing because those files won’t automatically be available in
|
|
||||||
// production build folder unless we copy them. However, copying the whole
|
|
||||||
// project directory is dangerous because we may expose sensitive files.
|
|
||||||
// Instead, we establish a convention that only files in `public` directory
|
|
||||||
// get served. Our build script will copy `public` into the `build` folder.
|
|
||||||
// In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
|
|
||||||
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
|
||||||
// In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
|
|
||||||
// Note that we only recommend to use `public` folder as an escape hatch
|
|
||||||
// for files like `favicon.ico`, `manifest.json`, and libraries that are
|
|
||||||
// for some reason broken when imported through Webpack. If you just want to
|
|
||||||
// use an image, put it in `src` and `import` it from JavaScript instead.
|
|
||||||
contentBase: paths.appPublic,
|
|
||||||
|
|
||||||
// By default files from `contentBase` will not trigger a page reload.
|
|
||||||
watchContentBase: true,
|
|
||||||
|
|
||||||
// Enable hot reloading server. It will provide /sockjs-node/ endpoint
|
|
||||||
// for the WebpackDevServer client so it can learn when the files were
|
|
||||||
// updated. The WebpackDevServer client is included as an entry point
|
|
||||||
// in the Webpack development configuration. Note that only changes
|
|
||||||
// to CSS are currently hot reloaded. JS changes will refresh the browser.
|
|
||||||
hot: true,
|
|
||||||
|
|
||||||
// It is important to tell WebpackDevServer to use the same "root" path
|
|
||||||
// as we specified in the config. In development, we always serve from /.
|
|
||||||
publicPath: '/',
|
|
||||||
|
|
||||||
// WebpackDevServer is noisy by default so we emit custom message instead
|
|
||||||
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
|
|
||||||
quiet: true,
|
|
||||||
|
|
||||||
// Reportedly, this avoids CPU overload on some systems.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/293
|
|
||||||
// src/node_modules is not ignored to support absolute imports
|
|
||||||
// https://github.com/facebook/create-react-app/issues/1065
|
|
||||||
watchOptions: {
|
|
||||||
ignored: ignoredFiles(paths.appSrc),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Enable HTTPS if the HTTPS environment variable is set to 'true'
|
|
||||||
https: protocol === 'https',
|
|
||||||
host,
|
|
||||||
overlay: false,
|
|
||||||
historyApiFallback: {
|
|
||||||
|
|
||||||
// Paths with dots should still use the history fallback.
|
|
||||||
// See https://github.com/facebook/create-react-app/issues/387.
|
|
||||||
disableDotRule: true,
|
|
||||||
},
|
|
||||||
public: allowedHost,
|
|
||||||
proxy,
|
|
||||||
before(app, server) {
|
|
||||||
if (fs.existsSync(paths.proxySetup)) {
|
|
||||||
// This registers user provided middleware for proxy reasons
|
|
||||||
require(paths.proxySetup)(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This lets us fetch source contents from webpack for the error overlay
|
|
||||||
app.use(evalSourceMapMiddleware(server));
|
|
||||||
|
|
||||||
// This lets us open files from the runtime error overlay.
|
|
||||||
app.use(errorOverlayMiddleware());
|
|
||||||
|
|
||||||
// This service worker file is effectively a 'no-op' that will reset any
|
|
||||||
// previous service worker registered for the same host:port combination.
|
|
||||||
// We do this in development to avoid hitting the production cache if
|
|
||||||
// it used the same host and port.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
|
|
||||||
app.use(noopServiceWorkerMiddleware(paths.publicUrl));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -9,29 +9,33 @@ module.exports = {
|
||||||
],
|
],
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
statements: 85,
|
statements: 90,
|
||||||
branches: 80,
|
branches: 80,
|
||||||
functions: 80,
|
functions: 85,
|
||||||
lines: 85,
|
lines: 90,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setupFiles: [ '<rootDir>/config/setupEnzyme.js' ],
|
setupFiles: ['<rootDir>/config/jest/setupBeforeEnzyme.js', '<rootDir>/config/jest/setupEnzyme.js'],
|
||||||
testMatch: [ '<rootDir>/test/**/*.test.{ts,tsx}' ],
|
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
||||||
|
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
testURL: 'http://localhost',
|
testEnvironmentOptions: {
|
||||||
|
url: 'http://localhost',
|
||||||
|
},
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
|
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
|
||||||
'^(?!.*\\.(ts|tsx|js|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
'^.+\\.scss$': '<rootDir>/config/jest/cssTransform.js',
|
||||||
|
'^(?!.*\\.(ts|tsx|js|json|scss)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'<rootDir>/.stryker-tmp',
|
'<rootDir>/.stryker-tmp',
|
||||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
'node_modules\/(?!(\@react-leaflet|react-leaflet|leaflet|react-chartjs-2|react-colorful)\/)',
|
||||||
'^.+\\.module\\.scss$',
|
'^.+\\.module\\.scss$',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^.+\\.module\\.scss$': 'identity-obj-proxy',
|
'^.+\\.module\\.scss$': 'identity-obj-proxy',
|
||||||
// Reactstrap module resolution does not work in jest for some reason. Manually mapping it solves the problem
|
'react-chartjs-2': '<rootDir>/node_modules/react-chartjs-2/dist/index.js',
|
||||||
'reactstrap': '<rootDir>/node_modules/reactstrap/dist/reactstrap.umd.js',
|
'uuid': '<rootDir>/node_modules/uuid/dist/index.js',
|
||||||
},
|
},
|
||||||
moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ],
|
moduleFileExtensions: ['js', 'ts', 'tsx', 'json'],
|
||||||
};
|
};
|
||||||
|
|
52389
package-lock.json
generated
52389
package-lock.json
generated
File diff suppressed because it is too large
Load diff
114
package.json
114
package.json
|
@ -12,15 +12,15 @@
|
||||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||||
"lint:css:fix": "npm run lint:css -- --fix",
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"start": "node scripts/start.js",
|
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
|
||||||
"serve:build": "serve ./build",
|
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.js",
|
||||||
"build": "node scripts/build.js && node scripts/replace-version.js",
|
|
||||||
"build:dist": "npm run build && node scripts/create-dist-file.js",
|
"build:dist": "npm run build && node scripts/create-dist-file.js",
|
||||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
"build:serve": "serve -p 5000 ./build",
|
||||||
|
"test": "jest --env=jsdom --colors --verbose",
|
||||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||||
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
"test:ci": "npm run test:coverage -- --coverageReporters=clover --ci",
|
||||||
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||||
|
@ -35,28 +35,30 @@
|
||||||
"chart.js": "^3.7.1",
|
"chart.js": "^3.7.1",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"compare-versions": "^4.1.3",
|
"compare-versions": "^4.1.3",
|
||||||
"csvjson": "^5.1.0",
|
"csvtojson": "^2.0.10",
|
||||||
"date-fns": "^2.28.0",
|
"date-fns": "^2.28.0",
|
||||||
"event-source-polyfill": "^1.0.25",
|
"event-source-polyfill": "^1.0.25",
|
||||||
|
"json2csv": "^5.0.7",
|
||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.7.1",
|
||||||
"qs": "^6.9.6",
|
"qs": "^6.9.6",
|
||||||
"ramda": "^0.27.2",
|
"ramda": "^0.27.2",
|
||||||
"react": "^17.0.2",
|
"react": "^18.1.0",
|
||||||
"react-chartjs-2": "^3.3.0",
|
"react-chartjs-2": "^4.1.0",
|
||||||
"react-colorful": "^5.5.1",
|
"react-colorful": "^5.5.1",
|
||||||
"react-copy-to-clipboard": "^5.0.4",
|
"react-copy-to-clipboard": "^5.0.4",
|
||||||
"react-datepicker": "^4.7.0",
|
"react-datepicker": "^4.7.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^18.1.0",
|
||||||
"react-external-link": "^1.2.2",
|
"react-external-link": "^2.0.0",
|
||||||
"react-leaflet": "^3.2.5",
|
"react-leaflet": "^4.0.0",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^8.0.0",
|
||||||
"react-router-dom": "^6.2.2",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-swipeable": "^6.2.0",
|
"react-swipeable": "^6.2.0",
|
||||||
"react-tag-autocomplete": "^6.3.0",
|
"react-tag-autocomplete": "^6.3.0",
|
||||||
"reactstrap": "^9.0.1",
|
"reactstrap": "^9.0.1",
|
||||||
"redux": "^4.1.2",
|
"redux": "^4.2.0",
|
||||||
"redux-localstorage-simple": "^2.4.1",
|
"redux-localstorage-simple": "^2.4.1",
|
||||||
"redux-thunk": "^2.4.1",
|
"redux-thunk": "^2.4.1",
|
||||||
|
"stream": "^0.0.2",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"workbox-core": "^6.5.1",
|
"workbox-core": "^6.5.1",
|
||||||
"workbox-expiration": "^6.5.1",
|
"workbox-expiration": "^6.5.1",
|
||||||
|
@ -65,84 +67,46 @@
|
||||||
"workbox-strategies": "^6.5.1"
|
"workbox-strategies": "^6.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.17.5",
|
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
|
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
|
"@stryker-mutator/core": "^6.0.2",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
"@stryker-mutator/jest-runner": "^6.0.2",
|
||||||
"@stryker-mutator/core": "^5.6.1",
|
"@stryker-mutator/typescript-checker": "^6.0.2",
|
||||||
"@stryker-mutator/jest-runner": "^5.6.1",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@stryker-mutator/typescript-checker": "^5.6.1",
|
"@testing-library/react": "^13.1.1",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@testing-library/user-event": "^14.1.1",
|
||||||
"@types/classnames": "^2.3.1",
|
|
||||||
"@types/enzyme": "^3.10.11",
|
"@types/enzyme": "^3.10.11",
|
||||||
"@types/jest": "^27.4.1",
|
"@types/jest": "^27.4.1",
|
||||||
|
"@types/json2csv": "^5.0.3",
|
||||||
"@types/leaflet": "^1.7.9",
|
"@types/leaflet": "^1.7.9",
|
||||||
"@types/qs": "^6.9.7",
|
"@types/qs": "^6.9.7",
|
||||||
"@types/ramda": "0.27.38",
|
"@types/ramda": "0.27.38",
|
||||||
"@types/react": "^17.0.39",
|
"@types/react": "^18.0.8",
|
||||||
"@types/react-color": "^3.0.6",
|
"@types/react-color": "^3.0.6",
|
||||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||||
"@types/react-datepicker": "^4.3.4",
|
"@types/react-datepicker": "^4.3.4",
|
||||||
"@types/react-dom": "^17.0.13",
|
"@types/react-dom": "^18.0.3",
|
||||||
"@types/react-leaflet": "^2.8.2",
|
|
||||||
"@types/react-redux": "^7.1.23",
|
|
||||||
"@types/react-tag-autocomplete": "^6.1.1",
|
"@types/react-tag-autocomplete": "^6.1.1",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
|
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
|
||||||
"adm-zip": "^0.5.9",
|
"adm-zip": "^0.5.9",
|
||||||
"autoprefixer": "^10.4.2",
|
"babel-jest": "^28.0.3",
|
||||||
"babel-jest": "^27.5.1",
|
"chalk": "^5.0.1",
|
||||||
"babel-loader": "^8.2.3",
|
|
||||||
"babel-plugin-named-asset-import": "^0.3.8",
|
|
||||||
"babel-preset-react-app": "10.0.0",
|
|
||||||
"babel-runtime": "^6.26.0",
|
|
||||||
"bfj": "^7.0.2",
|
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
|
||||||
"chalk": "^4.1.2",
|
|
||||||
"css-loader": "^5.0.1",
|
|
||||||
"dart-sass": "^1.25.0",
|
|
||||||
"dotenv": "^8.2.0",
|
|
||||||
"dotenv-expand": "^5.1.0",
|
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"eslint": "^7.13.0",
|
"eslint": "^8.12.0",
|
||||||
"eslint-loader": "^4.0.2",
|
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
|
||||||
"fs-extra": "^9.0.1",
|
|
||||||
"html-webpack-plugin": "^4.5.0",
|
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^27.5.1",
|
"jest": "^28.0.3",
|
||||||
"mini-css-extract-plugin": "^1.3.1",
|
"jest-canvas-mock": "^2.4.0",
|
||||||
"object-assign": "^4.1.1",
|
"jest-environment-jsdom": "^28.0.2",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
"react-scripts": "^5.0.1",
|
||||||
"pnp-webpack-plugin": "^1.7.0",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"postcss": "^8.4.8",
|
|
||||||
"postcss-flexbugs-fixes": "^4.2.1",
|
|
||||||
"postcss-loader": "^3.0.0",
|
|
||||||
"postcss-preset-env": "^6.7.0",
|
|
||||||
"postcss-safe-parser": "^5.0.2",
|
|
||||||
"react-dev-utils": "^11.0.0",
|
|
||||||
"resolve": "^1.22.0",
|
|
||||||
"sass": "^1.49.9",
|
"sass": "^1.49.9",
|
||||||
"sass-loader": "^10.1.0",
|
"serve": "^13.0.2",
|
||||||
"serve": "^12.0.0",
|
|
||||||
"stryker-cli": "^1.0.2",
|
"stryker-cli": "^1.0.2",
|
||||||
"style-loader": "^2.0.0",
|
"stylelint": "^14.8.2",
|
||||||
"stylelint": "^13.7.2",
|
|
||||||
"stylelint-config-adidas": "^1.3.0",
|
|
||||||
"stylelint-config-adidas-bem": "^1.2.0",
|
|
||||||
"stylelint-config-recommended-scss": "^4.2.0",
|
|
||||||
"stylelint-scss": "^3.18.0",
|
|
||||||
"sw-precache-webpack-plugin": "^1.0.0",
|
|
||||||
"terser-webpack-plugin": "^4.2.3",
|
|
||||||
"ts-mockery": "^1.2.0",
|
"ts-mockery": "^1.2.0",
|
||||||
"typescript": "^4.6.2",
|
"typescript": "^4.6.2",
|
||||||
"url-loader": "^4.1.1",
|
"webpack": "^5.70.0"
|
||||||
"webpack": "^4.46.0",
|
|
||||||
"webpack-dev-server": "^3.11.3",
|
|
||||||
"webpack-manifest-plugin": "^2.2.0",
|
|
||||||
"whatwg-fetch": "^3.6.2",
|
|
||||||
"workbox-webpack-plugin": "^6.5.1"
|
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|
182
scripts/build.js
182
scripts/build.js
|
@ -1,182 +0,0 @@
|
||||||
/* 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.
|
|
||||||
process.env.BABEL_ENV = 'production';
|
|
||||||
process.env.NODE_ENV = 'production';
|
|
||||||
|
|
||||||
// Makes the script crash on unhandled rejections instead of silently
|
|
||||||
// ignoring them. In the future, promise rejections that are not handled will
|
|
||||||
// terminate the Node.js process with a non-zero exit code.
|
|
||||||
process.on('unhandledRejection', (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure environment variables are read.
|
|
||||||
require('../config/env');
|
|
||||||
|
|
||||||
const chalk = require('chalk');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const bfj = require('bfj');
|
|
||||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
|
||||||
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
|
||||||
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
|
||||||
const printBuildError = require('react-dev-utils/printBuildError');
|
|
||||||
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
|
||||||
const paths = require('../config/paths');
|
|
||||||
const configFactory = require('../config/webpack.config');
|
|
||||||
|
|
||||||
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
|
|
||||||
|
|
||||||
// These sizes are pretty large. We'll warn for bundles exceeding them.
|
|
||||||
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
|
|
||||||
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // eslint-disable-line
|
|
||||||
|
|
||||||
const isInteractive = process.stdout.isTTY;
|
|
||||||
|
|
||||||
// Warn and crash if required files are missing
|
|
||||||
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process CLI arguments
|
|
||||||
const argvSliceStart = 2;
|
|
||||||
const argv = process.argv.slice(argvSliceStart);
|
|
||||||
const writeStatsJson = argv.includes('--stats');
|
|
||||||
|
|
||||||
// Generate configuration
|
|
||||||
const config = configFactory('production');
|
|
||||||
|
|
||||||
checkBrowsers(paths.appPath, isInteractive)
|
|
||||||
.then(() =>
|
|
||||||
|
|
||||||
// First, read the current file sizes in build directory.
|
|
||||||
// This lets us display how much they changed later.
|
|
||||||
measureFileSizesBeforeBuild(paths.appBuild))
|
|
||||||
.then((previousFileSizes) => {
|
|
||||||
// Remove all content but keep the directory so that
|
|
||||||
// if you're in it, you don't end up in Trash
|
|
||||||
fs.emptyDirSync(paths.appBuild);
|
|
||||||
|
|
||||||
// Merge with the public folder
|
|
||||||
copyPublicFolder();
|
|
||||||
|
|
||||||
// Start the webpack build
|
|
||||||
return build(previousFileSizes);
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
({ stats, previousFileSizes, warnings }) => {
|
|
||||||
if (warnings.length) {
|
|
||||||
console.log(chalk.yellow('Compiled with warnings.\n'));
|
|
||||||
console.log(warnings.join('\n\n'));
|
|
||||||
console.log(
|
|
||||||
`\nSearch for the ${
|
|
||||||
chalk.underline(chalk.yellow('keywords'))
|
|
||||||
} to learn more about each warning.`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`To ignore, add ${
|
|
||||||
chalk.cyan('// eslint-disable-next-line')
|
|
||||||
} to the line before.\n`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(chalk.green('Compiled successfully.\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('File sizes after gzip:\n');
|
|
||||||
printFileSizesAfterBuild(
|
|
||||||
stats,
|
|
||||||
previousFileSizes,
|
|
||||||
paths.appBuild,
|
|
||||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
|
||||||
WARN_AFTER_CHUNK_GZIP_SIZE,
|
|
||||||
);
|
|
||||||
console.log();
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.log(chalk.red('Failed to compile.\n'));
|
|
||||||
printBuildError(err);
|
|
||||||
process.exit(1);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
if (err && err.message) {
|
|
||||||
console.log(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the production build and print the deployment instructions.
|
|
||||||
function build(previousFileSizes) {
|
|
||||||
console.log('Creating an optimized production build...');
|
|
||||||
|
|
||||||
const compiler = webpack(config);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
compiler.run((err, stats) => {
|
|
||||||
let messages;
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
if (!err.message) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = formatWebpackMessages({
|
|
||||||
errors: [ err.message ],
|
|
||||||
warnings: [],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
messages = formatWebpackMessages(
|
|
||||||
stats.toJson({ all: false, warnings: true, errors: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (messages.errors.length) {
|
|
||||||
// Only keep the first error. Others are often indicative
|
|
||||||
// of the same problem, but confuse the reader with noise.
|
|
||||||
if (messages.errors.length > 1) {
|
|
||||||
messages.errors.length = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return reject(new Error(messages.errors.join('\n\n')));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
process.env.CI &&
|
|
||||||
(typeof process.env.CI !== 'string' ||
|
|
||||||
process.env.CI.toLowerCase() !== 'false') &&
|
|
||||||
messages.warnings.length
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
chalk.yellow(
|
|
||||||
'\nTreating warnings as errors because process.env.CI = true.\n' +
|
|
||||||
'Most CI servers set it automatically.\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return reject(new Error(messages.warnings.join('\n\n')));
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveArgs = {
|
|
||||||
stats,
|
|
||||||
previousFileSizes,
|
|
||||||
warnings: messages.warnings,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (writeStatsJson) {
|
|
||||||
return bfj // eslint-disable-line promise/no-promise-in-callback
|
|
||||||
.write(`${paths.appBuild}/bundle-stats.json`, stats.toJson())
|
|
||||||
.then(() => resolve(resolveArgs))
|
|
||||||
.catch((error) => reject(new Error(error)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolve(resolveArgs);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyPublicFolder() {
|
|
||||||
fs.copySync(paths.appPublic, paths.appBuild, {
|
|
||||||
dereference: true,
|
|
||||||
filter: (file) => file !== paths.appHtml,
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,12 +1,10 @@
|
||||||
/* 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';
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
const chalk = require('chalk');
|
const chalk = require('chalk');
|
||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs');
|
||||||
|
|
||||||
function zipDist(version) {
|
function zipDist(version) {
|
||||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs');
|
||||||
|
|
||||||
function replaceVersionPlaceholder(version) {
|
function replaceVersionPlaceholder(version) {
|
||||||
const staticJsFilesPath = './build/static/js';
|
const staticJsFilesPath = './build/static/js';
|
||||||
|
|
125
scripts/start.js
125
scripts/start.js
|
@ -1,125 +0,0 @@
|
||||||
/* 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.
|
|
||||||
process.env.BABEL_ENV = 'development';
|
|
||||||
process.env.NODE_ENV = 'development';
|
|
||||||
|
|
||||||
// Makes the script crash on unhandled rejections instead of silently
|
|
||||||
// ignoring them. In the future, promise rejections that are not handled will
|
|
||||||
// terminate the Node.js process with a non-zero exit code.
|
|
||||||
process.on('unhandledRejection', (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure environment variables are read.
|
|
||||||
require('../config/env');
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const chalk = require('chalk');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const WebpackDevServer = require('webpack-dev-server');
|
|
||||||
const clearConsole = require('react-dev-utils/clearConsole');
|
|
||||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
|
||||||
const {
|
|
||||||
choosePort,
|
|
||||||
createCompiler,
|
|
||||||
prepareProxy,
|
|
||||||
prepareUrls,
|
|
||||||
} = require('react-dev-utils/WebpackDevServerUtils');
|
|
||||||
const openBrowser = require('react-dev-utils/openBrowser');
|
|
||||||
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
|
||||||
const paths = require('../config/paths');
|
|
||||||
const configFactory = require('../config/webpack.config');
|
|
||||||
const createDevServerConfig = require('../config/webpackDevServer.config');
|
|
||||||
|
|
||||||
const useYarn = fs.existsSync(paths.yarnLockFile);
|
|
||||||
const isInteractive = process.stdout.isTTY;
|
|
||||||
|
|
||||||
// Warn and crash if required files are missing
|
|
||||||
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tools like Cloud9 rely on this.
|
|
||||||
const DEFAULT_PORT = 3000;
|
|
||||||
const PORT = parseInt(process.env.PORT) || DEFAULT_PORT;
|
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
|
||||||
|
|
||||||
if (process.env.HOST) {
|
|
||||||
console.log(
|
|
||||||
chalk.cyan(
|
|
||||||
`Attempting to bind to HOST environment variable: ${chalk.yellow(
|
|
||||||
chalk.bold(process.env.HOST),
|
|
||||||
)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.',
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`,
|
|
||||||
);
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
|
|
||||||
checkBrowsers(paths.appPath, isInteractive)
|
|
||||||
.then(() =>
|
|
||||||
|
|
||||||
// We attempt to use the default port but if it is busy, we offer the user to
|
|
||||||
// run on a different port. `choosePort()` Promise resolves to the next free port.
|
|
||||||
choosePort(HOST, PORT))
|
|
||||||
.then((port) => {
|
|
||||||
if (port === null) {
|
|
||||||
// We have not found a port.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = configFactory('development');
|
|
||||||
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
|
|
||||||
const appName = require(paths.appPackageJson).name;
|
|
||||||
|
|
||||||
const urls = prepareUrls(protocol, HOST, port);
|
|
||||||
|
|
||||||
// Create a webpack compiler that is configured with custom messages.
|
|
||||||
const compiler = createCompiler({ webpack, config, appName, urls, useYarn });
|
|
||||||
|
|
||||||
// Load proxy config
|
|
||||||
const proxySetting = require(paths.appPackageJson).proxy;
|
|
||||||
|
|
||||||
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
|
|
||||||
|
|
||||||
// Serve webpack assets generated by the compiler over a web server.
|
|
||||||
const serverConfig = createDevServerConfig(
|
|
||||||
proxyConfig,
|
|
||||||
urls.lanUrlForConfig,
|
|
||||||
);
|
|
||||||
const devServer = new WebpackDevServer(compiler, serverConfig);
|
|
||||||
|
|
||||||
// Launch WebpackDevServer.
|
|
||||||
devServer.listen(port, HOST, (err) => {
|
|
||||||
if (err) {
|
|
||||||
return console.log(err);
|
|
||||||
}
|
|
||||||
if (isInteractive) {
|
|
||||||
clearConsole();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(chalk.cyan('Starting the development server...\n'));
|
|
||||||
|
|
||||||
return openBrowser(urls.localUrlForBrowser);
|
|
||||||
});
|
|
||||||
|
|
||||||
[ 'SIGINT', 'SIGTERM' ].forEach((sig) => {
|
|
||||||
process.on(sig, () => {
|
|
||||||
devServer.close();
|
|
||||||
process.exit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err && err.message) {
|
|
||||||
console.log(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
|
@ -1,23 +0,0 @@
|
||||||
|
|
||||||
// Do this as the first thing so that any code reading it knows the right env.
|
|
||||||
process.env.BABEL_ENV = 'test';
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.PUBLIC_URL = '';
|
|
||||||
|
|
||||||
// Makes the script crash on unhandled rejections instead of silently
|
|
||||||
// ignoring them. In the future, promise rejections that are not handled will
|
|
||||||
// terminate the Node.js process with a non-zero exit code.
|
|
||||||
process.on('unhandledRejection', (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure environment variables are read.
|
|
||||||
require('../config/env');
|
|
||||||
|
|
||||||
// Make tests to be matched inside tests folder
|
|
||||||
const jest = require('jest');
|
|
||||||
|
|
||||||
const argumentsToRemove = 2;
|
|
||||||
const argv = process.argv.slice(argumentsToRemove);
|
|
||||||
|
|
||||||
jest.run(argv);
|
|
7
shlink-web-client.d.ts
vendored
7
shlink-web-client.d.ts
vendored
|
@ -7,11 +7,4 @@ declare module 'event-source-polyfill' {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'csvjson' {
|
|
||||||
export declare class CsvJson {
|
|
||||||
public toObject<T>(content: string): T[];
|
|
||||||
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key'; wrap?: true }): string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.png'
|
declare module '*.png'
|
||||||
|
|
|
@ -9,8 +9,10 @@ export interface ShlinkApiErrorProps {
|
||||||
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
|
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
|
||||||
<>
|
<>
|
||||||
{errorData?.detail ?? fallbackMessage}
|
{errorData?.detail ?? fallbackMessage}
|
||||||
{isInvalidArgumentError(errorData) &&
|
{isInvalidArgumentError(errorData) && (
|
||||||
<p className="mb-0">Invalid elements: [{errorData.invalidElements.join(', ')}]</p>
|
<p className="mb-0">
|
||||||
}
|
Invalid elements: [{errorData.invalidElements.join(', ')}]
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,7 +21,7 @@ import {
|
||||||
import { stringifyQuery } from '../../utils/helpers/query';
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
import { orderToString } from '../../utils/helpers/ordering';
|
import { orderToString } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : '';
|
const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : '');
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||||
const { orderBy = {}, ...rest } = params;
|
const { orderBy = {}, ...rest } = params;
|
||||||
|
@ -56,6 +56,10 @@ export default class ShlinkApiClient {
|
||||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
||||||
.then(({ data }) => data.visits);
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
|
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query)
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
||||||
.then(({ data }) => data.visits);
|
.then(({ data }) => data.visits);
|
||||||
|
@ -76,25 +80,12 @@ export default class ShlinkApiClient {
|
||||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||||
.then(() => {});
|
.then(() => {});
|
||||||
|
|
||||||
// eslint-disable-next-line valid-jsdoc
|
|
||||||
/**
|
|
||||||
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
|
|
||||||
*/
|
|
||||||
public readonly updateShortUrlTags = async (
|
|
||||||
shortCode: string,
|
|
||||||
domain: OptionalString,
|
|
||||||
tags: string[],
|
|
||||||
): Promise<string[]> =>
|
|
||||||
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
|
|
||||||
.then(({ data }) => data.tags);
|
|
||||||
|
|
||||||
public readonly updateShortUrl = async (
|
public readonly updateShortUrl = async (
|
||||||
shortCode: string,
|
shortCode: string,
|
||||||
domain: OptionalString,
|
domain: OptionalString,
|
||||||
data: ShlinkShortUrlData,
|
edit: ShlinkShortUrlData,
|
||||||
): Promise<ShortUrl> =>
|
): Promise<ShortUrl> =>
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, data)
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
|
||||||
.then(({ data }) => data);
|
|
||||||
|
|
||||||
public readonly listTags = async (): Promise<ShlinkTags> =>
|
public readonly listTags = async (): Promise<ShlinkTags> =>
|
||||||
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
||||||
|
|
|
@ -46,7 +46,7 @@ export interface ShlinkVisits {
|
||||||
|
|
||||||
export interface ShlinkVisitsOverview {
|
export interface ShlinkVisitsOverview {
|
||||||
visitsCount: number;
|
visitsCount: number;
|
||||||
orphanVisitsCount?: number; // Optional only for versions older than 2.6.0
|
orphanVisitsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkVisitsParams {
|
export interface ShlinkVisitsParams {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, FC } from 'react';
|
import { useEffect, FC } from 'react';
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import NotFound from '../common/NotFound';
|
import { NotFound } from '../common/NotFound';
|
||||||
import { ServersMap } from '../servers/data';
|
import { ServersMap } from '../servers/data';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { changeThemeInMarkup } from '../utils/theme';
|
import { changeThemeInMarkup } from '../utils/theme';
|
||||||
|
@ -17,13 +17,13 @@ interface AppProps {
|
||||||
appUpdated: boolean;
|
appUpdated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const App = (
|
export const App = (
|
||||||
MainHeader: FC,
|
MainHeader: FC,
|
||||||
Home: FC,
|
Home: FC,
|
||||||
MenuLayout: FC,
|
MenuLayout: FC,
|
||||||
CreateServer: FC,
|
CreateServer: FC,
|
||||||
EditServer: FC,
|
EditServer: FC,
|
||||||
Settings: FC,
|
SettingsComp: FC,
|
||||||
ManageServers: FC,
|
ManageServers: FC,
|
||||||
ShlinkVersionsContainer: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||||
|
@ -47,7 +47,7 @@ const App = (
|
||||||
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="/settings/*" element={<Settings />} />
|
<Route path="/settings/*" element={<SettingsComp />} />
|
||||||
<Route path="/manage-servers" element={<ManageServers />} />
|
<Route path="/manage-servers" element={<ManageServers />} />
|
||||||
<Route path="/server/create" element={<CreateServer />} />
|
<Route path="/server/create" element={<CreateServer />} />
|
||||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||||
|
@ -65,5 +65,3 @@ const App = (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
||||||
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
const initialState = false;
|
const initialState = false;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
import App from '../App';
|
import { App } from '../App';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
@ -17,7 +17,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
'ManageServers',
|
'ManageServers',
|
||||||
'ShlinkVersionsContainer',
|
'ShlinkVersionsContainer',
|
||||||
);
|
);
|
||||||
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||||
|
|
|
@ -13,7 +13,7 @@ interface AppUpdateBannerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
|
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
|
||||||
const [ isUpdating,, setUpdating ] = useToggle();
|
const [isUpdating,, setUpdating] = useToggle();
|
||||||
const update = () => {
|
const update = () => {
|
||||||
setUpdating();
|
setUpdating();
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
|
@ -24,7 +24,7 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forc
|
||||||
<h4 className="mb-4">This app has just been updated!</h4>
|
<h4 className="mb-4">This app has just been updated!</h4>
|
||||||
<p className="mb-0">
|
<p className="mb-0">
|
||||||
Restart it to enjoy the new features.
|
Restart it to enjoy the new features.
|
||||||
<Button disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
||||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
||||||
{isUpdating && <>Restarting...</>}
|
{isUpdating && <>Restarting...</>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
.aside-menu {
|
.aside-menu {
|
||||||
width: $asideMenuWidth;
|
width: $asideMenuWidth;
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
box-shadow: rgba(0, 0, 0, .05) 0 8px 15px;
|
box-shadow: rgb(0 0 0 / .05) 0 8px 15px;
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
padding-top: 13px;
|
padding-top: 13px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
transition: left 300ms;
|
transition: left 300ms;
|
||||||
top: $headerHeight - 3px;
|
top: $headerHeight - 3px;
|
||||||
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
|
box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@ import './AsideMenu.scss';
|
||||||
|
|
||||||
export interface AsideMenuProps {
|
export interface AsideMenuProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
className?: string;
|
|
||||||
showOnMobile?: boolean;
|
showOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,10 +6,10 @@ interface ErrorHandlerState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrorHandler = (
|
export const ErrorHandler = (
|
||||||
{ location }: Window,
|
{ location }: Window,
|
||||||
{ error }: Console,
|
{ error }: Console,
|
||||||
) => class ErrorHandler extends Component<any, ErrorHandlerState> {
|
) => class extends Component<any, ErrorHandlerState> {
|
||||||
public constructor(props: object) {
|
public constructor(props: object) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
|
@ -26,7 +26,8 @@ const ErrorHandler = (
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): ReactNode {
|
public render(): ReactNode {
|
||||||
if (this.state.hasError) {
|
const { hasError } = this.state;
|
||||||
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<SimpleCard className="p-4">
|
<SimpleCard className="p-4">
|
||||||
|
@ -39,8 +40,7 @@ const ErrorHandler = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.props.children;
|
const { children } = this.props;
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ErrorHandler;
|
|
||||||
|
|
|
@ -10,11 +10,11 @@ import { ServersMap } from '../servers/data';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './Home.scss';
|
import './Home.scss';
|
||||||
|
|
||||||
export interface HomeProps {
|
interface HomeProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = ({ servers }: HomeProps) => {
|
export const Home = ({ servers }: HomeProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const hasServers = !isEmpty(serversList);
|
const hasServers = !isEmpty(serversList);
|
||||||
|
@ -22,7 +22,6 @@ const Home = ({ servers }: HomeProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try to redirect to the first server marked as auto-connect
|
// Try to redirect to the first server marked as auto-connect
|
||||||
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||||
|
|
||||||
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -66,5 +65,3 @@ const Home = ({ servers }: HomeProps) => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Home;
|
|
||||||
|
|
|
@ -8,12 +8,12 @@ import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown: FC) => () => {
|
export const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
const [isOpen, toggleOpen, , close] = useToggle();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { pathname } = location;
|
const { pathname } = location;
|
||||||
|
|
||||||
useEffect(close, [ location ]);
|
useEffect(close, [location]);
|
||||||
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||||
|
@ -41,5 +41,3 @@ const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MainHeader;
|
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
z-index: 1035;
|
z-index: 1035;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(255, 255, 255, .5);
|
color: rgb(255 255 255 / .5);
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -5,9 +5,9 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||||
import { supportsDomainRedirects, supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
|
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import NotFound from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import { AsideMenuProps } from './AsideMenu';
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
import './MenuLayout.scss';
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
|
@ -16,13 +16,14 @@ interface MenuLayoutProps {
|
||||||
sidebarNotPresent: Function;
|
sidebarNotPresent: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuLayout = (
|
export const MenuLayout = (
|
||||||
TagsList: FC,
|
TagsList: FC,
|
||||||
ShortUrlsList: FC,
|
ShortUrlsList: FC,
|
||||||
AsideMenu: FC<AsideMenuProps>,
|
AsideMenu: FC<AsideMenuProps>,
|
||||||
CreateShortUrl: FC,
|
CreateShortUrl: FC,
|
||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
TagVisits: FC,
|
TagVisits: FC,
|
||||||
|
DomainVisits: FC,
|
||||||
OrphanVisits: FC,
|
OrphanVisits: FC,
|
||||||
NonOrphanVisits: FC,
|
NonOrphanVisits: FC,
|
||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
|
@ -31,10 +32,10 @@ const MenuLayout = (
|
||||||
ManageDomains: FC,
|
ManageDomains: FC,
|
||||||
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
|
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
|
||||||
const showContent = isReachableServer(selectedServer);
|
const showContent = isReachableServer(selectedServer);
|
||||||
|
|
||||||
useEffect(() => hideSidebar(), [ location ]);
|
useEffect(() => hideSidebar(), [location]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showContent && sidebarPresent();
|
showContent && sidebarPresent();
|
||||||
|
|
||||||
|
@ -45,9 +46,9 @@ const MenuLayout = (
|
||||||
return <ServerError />;
|
return <ServerError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
|
||||||
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
||||||
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||||
|
const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
|
|
||||||
|
@ -68,7 +69,8 @@ const MenuLayout = (
|
||||||
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
||||||
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
||||||
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
||||||
{addOrphanVisitsRoute && <Route path="/orphan-visits/*" element={<OrphanVisits />} />}
|
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
|
||||||
|
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
|
||||||
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
||||||
<Route path="/manage-tags" element={<TagsList />} />
|
<Route path="/manage-tags" element={<TagsList />} />
|
||||||
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
|
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
|
||||||
|
@ -84,5 +86,3 @@ const MenuLayout = (
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, ServerError);
|
}, ServerError);
|
||||||
|
|
||||||
export default MenuLayout;
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { FC } from 'react';
|
import { FC, PropsWithChildren } from 'react';
|
||||||
import './NoMenuLayout.scss';
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
export const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => (
|
||||||
|
<div className="no-menu-wrapper container-xl">{children}</div>
|
||||||
|
);
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import { FC } from 'react';
|
import { FC, PropsWithChildren } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
interface NotFoundProps {
|
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<SimpleCard className="p-4">
|
<SimpleCard className="p-4">
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
<h2>Oops! We could not find requested route.</h2>
|
||||||
|
@ -19,5 +17,3 @@ const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
import { FC, useEffect } from 'react';
|
import { FC, PropsWithChildren, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const ScrollToTop = (): FC => ({ children }) => {
|
export const ScrollToTop: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollTo(0, 0);
|
scrollTo(0, 0);
|
||||||
}, [ location ]);
|
}, [location]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ScrollToTop;
|
|
||||||
|
|
|
@ -22,9 +22,9 @@ const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
{isReachableServer(selectedServer) &&
|
{isReachableServer(selectedServer) && (
|
||||||
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||||
}
|
)}
|
||||||
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||||
</small>
|
</small>
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,7 +17,9 @@ interface SimplePaginatorProps {
|
||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
export const SimplePaginator: FC<SimplePaginatorProps> = (
|
||||||
|
{ pagesCount, currentPage, setCurrentPage, centered = true },
|
||||||
|
) => {
|
||||||
if (pagesCount < 2) {
|
if (pagesCount < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -35,7 +37,9 @@ const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, se
|
||||||
disabled={pageIsEllipsis(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{prettifyPageNumber(pageNumber)}</PaginationLink>
|
<PaginationLink role="link" tag="span" onClick={onClick(pageNumber)}>
|
||||||
|
{prettifyPageNumber(pageNumber)}
|
||||||
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
|
@ -44,5 +48,3 @@ const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, se
|
||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SimplePaginator;
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 5px 0 0 6px;
|
padding: 5px 0 0 6px;
|
||||||
border-radius: .3rem;
|
border-radius: .3rem;
|
||||||
background-color: var(--input-color);
|
background-color: var(--primary-color);
|
||||||
border: 1px solid var(--input-border-color);
|
border: 1px solid var(--input-border-color);
|
||||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||||
|
|
||||||
|
@ -16,6 +16,16 @@
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-group > .react-tags {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 1%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .react-tags {
|
||||||
|
background-color: var(--input-color);
|
||||||
|
}
|
||||||
|
|
||||||
.react-tags.is-focused {
|
.react-tags.is-focused {
|
||||||
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
||||||
}
|
}
|
||||||
|
@ -76,7 +86,7 @@
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
color: var(--input-text-color);
|
color: var(--input-text-color);
|
||||||
background-color: var(--input-color);
|
background-color: inherit;
|
||||||
|
|
||||||
/* prevent autoresize overflowing the container */
|
/* prevent autoresize overflowing the container */
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
@ -88,6 +98,10 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-tags__search-input::placeholder {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
.react-tags__search-input::-ms-clear {
|
.react-tags__search-input::-ms-clear {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -113,7 +127,7 @@
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, .2);
|
box-shadow: 0 2px 6px rgb(0 0 0 / .2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__suggestions li {
|
.react-tags__suggestions li {
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
|
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
|
||||||
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
|
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
export interface Sidebar {
|
export interface Sidebar {
|
||||||
sidebarPresent: boolean;
|
sidebarPresent: boolean;
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import { CsvJson } from 'csvjson';
|
|
||||||
import { NormalizedVisit } from '../../visits/types';
|
import { NormalizedVisit } from '../../visits/types';
|
||||||
import { ExportableShortUrl } from '../../short-urls/data';
|
import { ExportableShortUrl } from '../../short-urls/data';
|
||||||
import { saveCsv } from '../../utils/helpers/files';
|
import { saveCsv } from '../../utils/helpers/files';
|
||||||
|
import { JsonToCsv } from '../../utils/helpers/csvjson';
|
||||||
|
|
||||||
export class ReportExporter {
|
export class ReportExporter {
|
||||||
public constructor(
|
public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {}
|
||||||
private readonly window: Window,
|
|
||||||
private readonly csvjson: CsvJson,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
|
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
|
||||||
if (!visits.length) {
|
if (!visits.length) {
|
||||||
|
@ -26,7 +23,7 @@ export class ReportExporter {
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly exportCsv = (filename: string, rows: object[]) => {
|
private readonly exportCsv = (filename: string, rows: object[]) => {
|
||||||
const csv = this.csvjson.toCSV(rows, { headers: 'key', wrap: true });
|
const csv = this.jsonToCsv(rows);
|
||||||
|
|
||||||
saveCsv(this.window, csv, filename);
|
saveCsv(this.window, csv, filename);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Bottle from 'bottlejs';
|
import Bottle 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';
|
||||||
import MenuLayout from '../MenuLayout';
|
import { MenuLayout } from '../MenuLayout';
|
||||||
import AsideMenu from '../AsideMenu';
|
import AsideMenu from '../AsideMenu';
|
||||||
import ErrorHandler from '../ErrorHandler';
|
import { ErrorHandler } from '../ErrorHandler';
|
||||||
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
@ -20,16 +20,16 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.constant('axios', axios);
|
bottle.constant('axios', axios);
|
||||||
|
|
||||||
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||||
bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson');
|
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
|
||||||
|
|
||||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||||
|
|
||||||
bottle.serviceFactory('Home', () => Home);
|
bottle.serviceFactory('Home', () => Home);
|
||||||
bottle.decorator('Home', withoutSelectedServer);
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'MenuLayout',
|
'MenuLayout',
|
||||||
|
@ -40,6 +40,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
'TagVisits',
|
'TagVisits',
|
||||||
|
'DomainVisits',
|
||||||
'OrphanVisits',
|
'OrphanVisits',
|
||||||
'NonOrphanVisits',
|
'NonOrphanVisits',
|
||||||
'ServerError',
|
'ServerError',
|
||||||
|
@ -47,12 +48,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
'EditShortUrl',
|
'EditShortUrl',
|
||||||
'ManageDomains',
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer', 'sidebarPresent', 'sidebarNotPresent' ]));
|
bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
||||||
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer', 'sidebar' ]));
|
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer', 'sidebar']));
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||||
|
|
||||||
|
|
|
@ -20,8 +20,8 @@ const bottle = new Bottle();
|
||||||
|
|
||||||
export const { container } = bottle;
|
export const { container } = bottle;
|
||||||
|
|
||||||
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
|
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
|
||||||
(...args: any[]) => (container[serviceName] as T)(...args) as K;
|
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
||||||
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
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
|
||||||
|
|
|
@ -5,11 +5,12 @@ import reducers from '../reducers';
|
||||||
import { migrateDeprecatedSettings } from '../settings/helpers';
|
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||||
import { ShlinkState } from './types';
|
import { ShlinkState } from './types';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV !== 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
// eslint-disable-next-line no-mixed-operators
|
||||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
|
|
||||||
const localStorageConfig: RLSOptions = {
|
const localStorageConfig: RLSOptions = {
|
||||||
states: [ 'settings', 'servers' ],
|
states: ['settings', 'servers'],
|
||||||
namespace: 'shlink',
|
namespace: 'shlink',
|
||||||
namespaceSeparator: '.',
|
namespaceSeparator: '.',
|
||||||
debounce: 300,
|
debounce: 300,
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { DomainsList } from '../domains/reducers/domainsList';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { VisitsInfo } from '../visits/types';
|
import { VisitsInfo } from '../visits/types';
|
||||||
import { Sidebar } from '../common/reducers/sidebar';
|
import { Sidebar } from '../common/reducers/sidebar';
|
||||||
|
import { DomainVisits } from '../visits/reducers/domainVisits';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
|
@ -25,6 +26,7 @@ export interface ShlinkState {
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
shortUrlVisits: ShortUrlVisits;
|
shortUrlVisits: ShortUrlVisits;
|
||||||
tagVisits: TagVisits;
|
tagVisits: TagVisits;
|
||||||
|
domainVisits: DomainVisits;
|
||||||
orphanVisits: VisitsInfo;
|
orphanVisits: VisitsInfo;
|
||||||
nonOrphanVisits: VisitsInfo;
|
nonOrphanVisits: VisitsInfo;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
|
|
|
@ -1,19 +1,13 @@
|
||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
faBan as forbiddenIcon,
|
|
||||||
faDotCircle as defaultDomainIcon,
|
|
||||||
faEdit as editIcon,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { ShlinkDomainRedirects } from '../api/types';
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import { OptionalString } from '../utils/utils';
|
import { OptionalString } from '../utils/utils';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
|
|
||||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
|
||||||
import { Domain } from './data';
|
import { Domain } from './data';
|
||||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||||
|
import { DomainDropdown } from './helpers/DomainDropdown';
|
||||||
|
|
||||||
interface DomainRowProps {
|
interface DomainRowProps {
|
||||||
domain: Domain;
|
domain: Domain;
|
||||||
|
@ -39,9 +33,7 @@ const DefaultDomain: FC = () => (
|
||||||
export const DomainRow: FC<DomainRowProps> = (
|
export const DomainRow: FC<DomainRowProps> = (
|
||||||
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
||||||
) => {
|
) => {
|
||||||
const [ isOpen, toggle ] = useToggle();
|
|
||||||
const { domain: authority, isDefault, redirects, status } = domain;
|
const { domain: authority, isDefault, redirects, status } = domain;
|
||||||
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkDomainHealth(domain.domain);
|
checkDomainHealth(domain.domain);
|
||||||
|
@ -64,25 +56,8 @@ export const DomainRow: FC<DomainRowProps> = (
|
||||||
<DomainStatusIcon status={status} />
|
<DomainStatusIcon status={status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-end">
|
<td className="responsive-table__cell text-end">
|
||||||
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
|
||||||
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
{!canEditDomain && (
|
|
||||||
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
|
|
||||||
Redirects for default domain cannot be edited here.
|
|
||||||
<br />
|
|
||||||
Use config options or env vars directly on the server.
|
|
||||||
</UncontrolledTooltip>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<EditDomainRedirectsModal
|
|
||||||
domain={domain}
|
|
||||||
isOpen={isOpen}
|
|
||||||
toggle={toggle}
|
|
||||||
editDomainRedirects={editDomainRedirects}
|
|
||||||
/>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,7 +19,7 @@ interface DomainSelectorConnectProps extends DomainSelectorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
|
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
|
||||||
const [ inputDisplayed,, showInput, hideInput ] = useToggle();
|
const [inputDisplayed,, showInput, hideInput] = useToggle();
|
||||||
const { domains } = domainsList;
|
const { domains } = domainsList;
|
||||||
const valueIsEmpty = isEmpty(value);
|
const valueIsEmpty = isEmpty(value);
|
||||||
const unselectDomain = () => onChange('');
|
const unselectDomain = () => onChange('');
|
||||||
|
@ -40,6 +40,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
||||||
outline
|
outline
|
||||||
type="button"
|
type="button"
|
||||||
className="domains-dropdown__back-btn"
|
className="domains-dropdown__back-btn"
|
||||||
|
aria-label="Back to domains list"
|
||||||
onClick={pipe(unselectDomain, hideInput)}
|
onClick={pipe(unselectDomain, hideInput)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faUndo} />
|
<FontAwesomeIcon icon={faUndo} />
|
||||||
|
@ -56,7 +57,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
||||||
{domains.map(({ domain, isDefault }) => (
|
{domains.map(({ domain, isDefault }) => (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={domain}
|
key={domain}
|
||||||
active={value === domain || isDefault && valueIsEmpty}
|
active={(value === domain || isDefault) && valueIsEmpty}
|
||||||
onClick={() => onChange(domain)}
|
onClick={() => onChange(domain)}
|
||||||
>
|
>
|
||||||
{domain}
|
{domain}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import Message from '../utils/Message';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SearchField from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { ShlinkDomainRedirects } from '../api/types';
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { DomainsList } from './reducers/domainsList';
|
import { DomainsList } from './reducers/domainsList';
|
||||||
|
@ -18,7 +18,7 @@ interface ManageDomainsProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
|
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
|
||||||
|
|
||||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
||||||
|
|
51
src/domains/helpers/DomainDropdown.tsx
Normal file
51
src/domains/helpers/DomainDropdown.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
|
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
||||||
|
import { Domain } from '../data';
|
||||||
|
import { ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
|
||||||
|
import { getServerId, SelectedServer } from '../../servers/data';
|
||||||
|
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||||
|
|
||||||
|
interface DomainDropdownProps {
|
||||||
|
domain: Domain;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
|
||||||
|
const [isOpen, toggle] = useToggle();
|
||||||
|
const [isModalOpen, toggleModal] = useToggle();
|
||||||
|
const { isDefault } = domain;
|
||||||
|
const canBeEdited = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
||||||
|
const withVisits = supportsDomainVisits(selectedServer);
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownBtnMenu isOpen={isOpen} toggle={toggle}>
|
||||||
|
{withVisits && (
|
||||||
|
<DropdownItem
|
||||||
|
tag={Link}
|
||||||
|
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
<EditDomainRedirectsModal
|
||||||
|
domain={domain}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
toggle={toggleModal}
|
||||||
|
editDomainRedirects={editDomainRedirects}
|
||||||
|
/>
|
||||||
|
</DropdownBtnMenu>
|
||||||
|
);
|
||||||
|
};
|
|
@ -18,7 +18,7 @@ interface DomainStatusIconProps {
|
||||||
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
||||||
const ref = useRef<HTMLSpanElement>();
|
const ref = useRef<HTMLSpanElement>();
|
||||||
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
||||||
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile());
|
const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = () => setIsMobile(matchesMobile());
|
const listener = () => setIsMobile(matchesMobile());
|
||||||
|
|
|
@ -25,9 +25,9 @@ const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...
|
||||||
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||||
{ isOpen, toggle, domain, editDomainRedirects },
|
{ isOpen, toggle, domain, editDomainRedirects },
|
||||||
) => {
|
) => {
|
||||||
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
const [baseUrlRedirect, setBaseUrlRedirect] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||||
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? '');
|
const [regular404Redirect, setRegular404Redirect] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||||
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(
|
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
|
||||||
domain.redirects?.invalidShortUrlRedirect ?? '',
|
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||||
);
|
);
|
||||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||||
|
|
|
@ -5,11 +5,9 @@ import { GetState } from '../../container/types';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
||||||
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
||||||
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
export interface EditDomainRedirectsAction extends Action<string> {
|
export interface EditDomainRedirectsAction extends Action<string> {
|
||||||
domain: string;
|
domain: string;
|
||||||
|
@ -21,10 +19,10 @@ export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder
|
||||||
domainRedirects: Partial<ShlinkDomainRedirects>,
|
domainRedirects: Partial<ShlinkDomainRedirects>,
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
||||||
const { editDomainRedirects } = buildShlinkApiClient(getState);
|
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
|
||||||
|
|
||||||
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
@ -10,13 +10,11 @@ import { hasServerData } from '../../servers/data';
|
||||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||||
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
export interface DomainsList {
|
export interface DomainsList {
|
||||||
domains: Domain[];
|
domains: Domain[];
|
||||||
|
@ -55,10 +53,10 @@ export type DomainsCombinedAction = ListDomainsAction
|
||||||
& ValidateDomain;
|
& ValidateDomain;
|
||||||
|
|
||||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||||
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects };
|
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
|
||||||
|
|
||||||
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||||
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status };
|
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
||||||
|
|
||||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
|
@ -86,15 +84,15 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||||
getState: GetState,
|
getState: GetState,
|
||||||
) => {
|
) => {
|
||||||
dispatch({ type: LIST_DOMAINS_START });
|
dispatch({ type: LIST_DOMAINS_START });
|
||||||
const { listDomains } = buildShlinkApiClient(getState);
|
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({
|
const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
|
||||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||||
defaultRedirects,
|
defaultRedirects,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects });
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,12 @@ import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||||
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
|
||||||
|
|
||||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
bottle.decorator('ManageDomains', connect(
|
bottle.decorator('ManageDomains', connect(
|
||||||
[ 'domainsList', 'selectedServer' ],
|
['domainsList', 'selectedServer'],
|
||||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
|
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
||||||
));
|
));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
|
@ -49,7 +49,7 @@ a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.bt
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
|
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { render } from 'react-dom';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { homepage } from '../package.json';
|
import pack from '../package.json';
|
||||||
import { container } from './container';
|
import { container } from './container';
|
||||||
import { store } from './container/store';
|
import { store } from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
|
import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking
|
||||||
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 './index.scss';
|
import './index.scss';
|
||||||
|
@ -15,9 +16,9 @@ fixLeafletIcons();
|
||||||
|
|
||||||
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||||
|
|
||||||
render(
|
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter basename={homepage}>
|
<BrowserRouter basename={pack.homepage}>
|
||||||
<ErrorHandler>
|
<ErrorHandler>
|
||||||
<ScrollToTop>
|
<ScrollToTop>
|
||||||
<App />
|
<App />
|
||||||
|
@ -25,14 +26,11 @@ render(
|
||||||
</ErrorHandler>
|
</ErrorHandler>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>,
|
</Provider>,
|
||||||
document.getElementById('root'),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// If you want your app to work offline and load faster, you can change
|
|
||||||
// unregister() to register() below. Note this comes with some pitfalls.
|
|
||||||
// Learn more about service workers: https://cra.link/PWA
|
// Learn more about service workers: https://cra.link/PWA
|
||||||
registerServiceWorker({
|
registerServiceWorker({
|
||||||
onUpdate() {
|
onUpdate() {
|
||||||
store.dispatch(appUpdateAvailable()); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
store.dispatch(appUpdateAvailable());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -23,7 +23,7 @@ export function boundToMercureHub<T = {}>(
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
|
||||||
const topics = getTopicsForProps(props, params);
|
const topics = getTopicsForProps(props, params);
|
||||||
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
|
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
|
||||||
|
|
||||||
|
@ -32,12 +32,12 @@ export function boundToMercureHub<T = {}>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
createNewVisits([ ...pendingUpdates ]);
|
createNewVisits([...pendingUpdates]);
|
||||||
pendingUpdates.clear();
|
pendingUpdates.clear();
|
||||||
}, interval * 1000 * 60);
|
}, interval * 1000 * 60);
|
||||||
|
|
||||||
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
||||||
}, [ mercureInfo ]);
|
}, [mercureInfo]);
|
||||||
|
|
||||||
return <WrappedComponent {...props} />;
|
return <WrappedComponent {...props} />;
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,11 +4,9 @@ import { GetState } from '../../container/types';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/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_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
export interface MercureInfo {
|
export interface MercureInfo {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
|
|
@ -7,6 +7,7 @@ import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||||
|
import domainVisitsReducer from '../visits/reducers/domainVisits';
|
||||||
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
||||||
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
||||||
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
||||||
|
@ -30,6 +31,7 @@ export default combineReducers<ShlinkState>({
|
||||||
shortUrlEdition: shortUrlEditionReducer,
|
shortUrlEdition: shortUrlEditionReducer,
|
||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlVisits: shortUrlVisitsReducer,
|
||||||
tagVisits: tagVisitsReducer,
|
tagVisits: tagVisitsReducer,
|
||||||
|
domainVisits: domainVisitsReducer,
|
||||||
orphanVisits: orphanVisitsReducer,
|
orphanVisits: orphanVisitsReducer,
|
||||||
nonOrphanVisits: nonOrphanVisitsReducer,
|
nonOrphanVisits: nonOrphanVisitsReducer,
|
||||||
shortUrlDetail: shortUrlDetailReducer,
|
shortUrlDetail: shortUrlDetailReducer,
|
||||||
|
|
|
@ -32,10 +32,10 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const hasServers = !!Object.keys(servers).length;
|
const hasServers = !!Object.keys(servers).length;
|
||||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [serversImported, setServersImported] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
|
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||||
const [ serverData, setServerData ] = useState<ServerData | undefined>();
|
const [serverData, setServerData] = useState<ServerData | undefined>();
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!serverData) {
|
if (!serverData) {
|
||||||
return;
|
return;
|
||||||
|
@ -53,13 +53,14 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||||
);
|
);
|
||||||
|
|
||||||
serverExists ? toggleConfirmModal() : save();
|
serverExists ? toggleConfirmModal() : save();
|
||||||
}, [ serverData ]);
|
}, [serverData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
|
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
|
||||||
{!hasServers &&
|
{!hasServers && (
|
||||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
|
||||||
|
)}
|
||||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||||
<Button outline color="primary" className="ms-2">Create server</Button>
|
<Button outline color="primary" className="ms-2">Create server</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
@ -69,7 +70,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
isOpen={isConfirmModalOpen}
|
isOpen={isConfirmModalOpen}
|
||||||
duplicatedServers={serverData ? [ serverData ] : []}
|
duplicatedServers={serverData ? [serverData] : []}
|
||||||
onDiscard={goBack}
|
onDiscard={goBack}
|
||||||
onSave={save}
|
onSave={save}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import { FC } from 'react';
|
import { FC, PropsWithChildren } from 'react';
|
||||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { DeleteServerModalProps } from './DeleteServerModal';
|
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
export interface DeleteServerButtonProps {
|
export type DeleteServerButtonProps = PropsWithChildren<{
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
|
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
|
||||||
{ server, className, children, textClassName },
|
{ server, className, children, textClassName },
|
||||||
) => {
|
) => {
|
||||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
const [isModalOpen, , showModal, hideModal] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
|
@ -37,8 +37,8 @@ const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||||
</p>
|
</p>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||||
<button className="btn btn-danger" onClick={() => closeModal()}>Delete</button>
|
<Button color="danger" onClick={() => closeModal()}>Delete</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SearchField from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
|
@ -26,16 +26,16 @@ export const ManageServers = (
|
||||||
ManageServersRow: FC<ManageServersRowProps>,
|
ManageServersRow: FC<ManageServersRowProps>,
|
||||||
): FC<ManageServersProps> => ({ servers }) => {
|
): FC<ManageServersProps> => ({ servers }) => {
|
||||||
const allServers = Object.values(servers);
|
const allServers = Object.values(servers);
|
||||||
const [ serversList, setServersList ] = useState(allServers);
|
const [serversList, setServersList] = useState(allServers);
|
||||||
const filterServers = (searchTerm: string) => setServersList(
|
const filterServers = (searchTerm: string) => setServersList(
|
||||||
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
|
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
|
||||||
);
|
);
|
||||||
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
|
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
|
||||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setServersList(Object.values(servers));
|
setServersList(Object.values(servers));
|
||||||
}, [ servers ]);
|
}, [servers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
|
@ -61,17 +61,17 @@ export const ManageServers = (
|
||||||
<table className="table table-hover responsive-table mb-0">
|
<table className="table table-hover responsive-table mb-0">
|
||||||
<thead className="responsive-table__header">
|
<thead className="responsive-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
{hasAutoConnect && <th aria-label="Auto-connect" style={{ width: '50px' }} />}
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Base URL</th>
|
<th>Base URL</th>
|
||||||
<th />
|
<th aria-label="Options" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
||||||
{serversList.map((server) =>
|
{serversList.map((server) => (
|
||||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />)
|
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
||||||
}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
|
@ -25,8 +25,8 @@ interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownP
|
||||||
export const ManageServersRowDropdown = (
|
export const ManageServersRowDropdown = (
|
||||||
DeleteServerModal: FC<DeleteServerModalProps>,
|
DeleteServerModal: FC<DeleteServerModalProps>,
|
||||||
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
||||||
const [ isMenuOpen, toggleMenu ] = useToggle();
|
const [isMenuOpen, toggleMenu] = useToggle();
|
||||||
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||||
const serverUrl = `/server/${server.id}`;
|
const serverUrl = `/server/${server.id}`;
|
||||||
const { autoConnect: isAutoConnect } = server;
|
const { autoConnect: isAutoConnect } = server;
|
||||||
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||||
|
|
|
@ -8,10 +8,9 @@ import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { Versions } from '../utils/helpers/version';
|
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
|
import { supportsNonOrphanVisits } from '../utils/helpers/features';
|
||||||
import { getServerId, SelectedServer } from './data';
|
import { getServerId, SelectedServer } from './data';
|
||||||
import { HighlightCard } from './helpers/HighlightCard';
|
import { HighlightCard } from './helpers/HighlightCard';
|
||||||
|
|
||||||
|
@ -28,7 +27,6 @@ interface OverviewConnectProps {
|
||||||
export const Overview = (
|
export const Overview = (
|
||||||
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
||||||
CreateShortUrl: FC<CreateShortUrlProps>,
|
CreateShortUrl: FC<CreateShortUrlProps>,
|
||||||
ForServerVersion: FC<Versions>,
|
|
||||||
) => boundToMercureHub(({
|
) => boundToMercureHub(({
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
|
@ -42,7 +40,6 @@ export const Overview = (
|
||||||
const { loading: loadingTags } = tagsList;
|
const { loading: loadingTags } = tagsList;
|
||||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||||
const serverId = getServerId(selectedServer);
|
const serverId = getServerId(selectedServer);
|
||||||
const linkToOrphanVisits = supportsOrphanVisits(selectedServer);
|
|
||||||
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
|
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
@ -61,13 +58,8 @@ export const Overview = (
|
||||||
</HighlightCard>
|
</HighlightCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<HighlightCard title="Orphan visits" link={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}>
|
<HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}>
|
||||||
<ForServerVersion minVersion="2.6.0">
|
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount)}
|
||||||
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
|
|
||||||
</ForServerVersion>
|
|
||||||
<ForServerVersion maxVersion="2.5.*">
|
|
||||||
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
|
|
||||||
</ForServerVersion>
|
|
||||||
</HighlightCard>
|
</HighlightCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
|
@ -109,4 +101,4 @@ export const Overview = (
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
}, () => [Topics.visits, Topics.orphanVisits]);
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
.servers-list__list-group:not(.servers-list__list-group--embedded) {
|
.servers-list__list-group:not(.servers-list__list-group--embedded) {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
|
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
|
||||||
}
|
}
|
||||||
|
|
||||||
.servers-list__server-item.servers-list__server-item {
|
.servers-list__server-item.servers-list__server-item {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { FC } from 'react';
|
import { FC, PropsWithChildren } from 'react';
|
||||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -7,10 +7,10 @@ import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
import './ServersListGroup.scss';
|
import './ServersListGroup.scss';
|
||||||
|
|
||||||
interface ServersListGroupProps {
|
type ServersListGroupProps = PropsWithChildren<{
|
||||||
servers: ServerWithId[];
|
servers: ServerWithId[];
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||||
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
|
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
|
||||||
|
|
|
@ -35,15 +35,15 @@ export const hasServerData = (server: SelectedServer | ServerData): server is Se
|
||||||
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
|
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
|
||||||
|
|
||||||
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
|
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
|
||||||
!!server?.hasOwnProperty('id');
|
!!(server as ServerWithId)?.id;
|
||||||
|
|
||||||
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
|
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
|
||||||
!!server?.hasOwnProperty('version');
|
!!(server as ReachableServer)?.version;
|
||||||
|
|
||||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||||
!!server?.hasOwnProperty('serverNotFound');
|
!!(server as NotFoundServer)?.serverNotFound;
|
||||||
|
|
||||||
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
|
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
|
||||||
|
|
||||||
export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
|
export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
|
||||||
omit<ServerWithId, 'id' | 'autoConnect'>([ 'id', 'autoConnect' ], server);
|
omit<ServerWithId, 'id' | 'autoConnect'>(['id', 'autoConnect'], server);
|
||||||
|
|
|
@ -20,12 +20,12 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||||
<ul>
|
<ul>
|
||||||
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? (
|
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
<li>URL: <b>{url}</b></li>
|
<li>URL: <b>{url}</b></li>
|
||||||
<li>API key: <b>{apiKey}</b></li>
|
<li>API key: <b>{apiKey}</b></li>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)}
|
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
||||||
</ul>
|
</ul>
|
||||||
<span>
|
<span>
|
||||||
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { 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 <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ForServerVersion;
|
|
|
@ -1,16 +1,16 @@
|
||||||
import { FC } from 'react';
|
import { FC, PropsWithChildren } from 'react';
|
||||||
import { Card, CardText, CardTitle } from 'reactstrap';
|
import { Card, CardText, CardTitle } from 'reactstrap';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import './HighlightCard.scss';
|
import './HighlightCard.scss';
|
||||||
|
|
||||||
export interface HighlightCardProps {
|
export type HighlightCardProps = PropsWithChildren<{
|
||||||
title: string;
|
title: string;
|
||||||
link?: string | false;
|
link?: string | false;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
const buildExtraProps = (link?: string | false) => !link ? {} : { tag: Link, to: link };
|
const buildExtraProps = (link?: string | false) => (!link ? {} : { tag: Link, to: link });
|
||||||
|
|
||||||
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => (
|
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => (
|
||||||
<Card className="highlight-card" body {...buildExtraProps(link)}>
|
<Card className="highlight-card" body {...buildExtraProps(link)}>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react';
|
import { useRef, RefObject, ChangeEvent, MutableRefObject, useState, useEffect, FC, PropsWithChildren } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { complement, pipe } from 'ramda';
|
import { complement, pipe } from 'ramda';
|
||||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
@ -11,12 +11,12 @@ import './ImportServersBtn.scss';
|
||||||
|
|
||||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||||
|
|
||||||
export interface ImportServersBtnProps {
|
export type ImportServersBtnProps = PropsWithChildren<{
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onImportError?: (error: Error) => void;
|
onImportError?: (error: Error) => void;
|
||||||
tooltipPlacement?: 'top' | 'bottom';
|
tooltipPlacement?: 'top' | 'bottom';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
createServers: (servers: ServerData[]) => void;
|
createServers: (servers: ServerData[]) => void;
|
||||||
|
@ -38,9 +38,9 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||||
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
|
const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>();
|
||||||
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
|
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||||
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||||
const create = pipe(createServers, onImport);
|
const create = pipe(createServers, onImport);
|
||||||
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
|
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
|
||||||
const createNonDuplicatedServers = pipe(
|
const createNonDuplicatedServers = pipe(
|
||||||
|
@ -52,7 +52,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
|
||||||
.then(setServersToCreate)
|
.then(setServersToCreate)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reset input after processing file
|
// Reset input after processing file
|
||||||
(target as { value: string | null }).value = null;
|
(target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign
|
||||||
})
|
})
|
||||||
.catch(onImportError);
|
.catch(onImportError);
|
||||||
|
|
||||||
|
@ -62,12 +62,12 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingServers = Object.values(servers);
|
const existingServers = Object.values(servers);
|
||||||
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
|
const dupServers = serversToCreate.filter(serversFiltering(existingServers));
|
||||||
const hasDuplicatedServers = !!duplicatedServers.length;
|
const hasDuplicatedServers = !!dupServers.length;
|
||||||
|
|
||||||
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
|
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(dupServers);
|
||||||
hasDuplicatedServers && showModal();
|
hasDuplicatedServers && showModal();
|
||||||
}, [ serversToCreate ]);
|
}, [serversToCreate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,29 +1,29 @@
|
||||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
import { FC, PropsWithChildren, ReactNode, useEffect, useState } from 'react';
|
||||||
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
|
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
import { SimpleCard } from '../../utils/SimpleCard';
|
import { SimpleCard } from '../../utils/SimpleCard';
|
||||||
|
|
||||||
interface ServerFormProps {
|
type ServerFormProps = PropsWithChildren<{
|
||||||
onSubmit: (server: ServerData) => void;
|
onSubmit: (server: ServerData) => void;
|
||||||
initialValues?: ServerData;
|
initialValues?: ServerData;
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||||
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 = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
|
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialValues && setName(initialValues.name);
|
initialValues && setName(initialValues.name);
|
||||||
initialValues && setUrl(initialValues.url);
|
initialValues && setUrl(initialValues.url);
|
||||||
initialValues && setApiKey(initialValues.apiKey);
|
initialValues && setApiKey(initialValues.apiKey);
|
||||||
}, [ initialValues ]);
|
}, [initialValues]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="server-form" onSubmit={handleSubmit}>
|
<form className="server-form" name="serverForm" onSubmit={handleSubmit}>
|
||||||
<SimpleCard className="mb-3" title={title}>
|
<SimpleCard className="mb-3" title={title}>
|
||||||
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
||||||
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
||||||
|
|
|
@ -16,7 +16,7 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
params.serverId && selectServer(params.serverId);
|
params.serverId && selectServer(params.serverId);
|
||||||
}, [ params.serverId ]);
|
}, [params.serverId]);
|
||||||
|
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -6,8 +6,9 @@ interface WithoutSelectedServerProps {
|
||||||
|
|
||||||
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
|
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
|
||||||
return (props: WithoutSelectedServerProps & T) => {
|
return (props: WithoutSelectedServerProps & T) => {
|
||||||
|
const { resetSelectedServer } = props;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.resetSelectedServer();
|
resetSelectedServer();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <WrappedComponent {...props} />;
|
return <WrappedComponent {...props} />;
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import { pipe, prop } from 'ramda';
|
import { pipe, prop } from 'ramda';
|
||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { homepage } from '../../../package.json';
|
import pack from '../../../package.json';
|
||||||
import { hasServerData, ServerData } from '../data';
|
import { hasServerData, ServerData } from '../data';
|
||||||
import { createServers } from './servers';
|
import { createServers } from './servers';
|
||||||
|
|
||||||
const responseToServersList = pipe(
|
const responseToServersList = pipe(
|
||||||
prop<any, any>('data'),
|
prop<any, any>('data'),
|
||||||
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [],
|
(data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||||
const resp = await get(`${homepage}/servers.json`);
|
const resp = await get(`${pack.homepage}/servers.json`);
|
||||||
const remoteList = responseToServersList(resp);
|
const remoteList = responseToServersList(resp);
|
||||||
|
|
||||||
dispatch(createServers(remoteList));
|
dispatch(createServers(remoteList));
|
||||||
|
|
|
@ -7,21 +7,19 @@ import { ShlinkHealth } from '../../api/types';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
||||||
|
|
||||||
export const MIN_FALLBACK_VERSION = '1.0.0';
|
export const MIN_FALLBACK_VERSION = '1.0.0';
|
||||||
export const MAX_FALLBACK_VERSION = '999.999.999';
|
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 */
|
|
||||||
|
|
||||||
export interface SelectServerAction extends Action<string> {
|
export interface SelectServerAction extends Action<string> {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionToSemVer = pipe(
|
const versionToSemVer = pipe(
|
||||||
(version: string) => 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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,10 @@ import { Action } from 'redux';
|
||||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||||
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
export interface CreateServersAction extends Action<string> {
|
export interface CreateServersAction extends Action<string> {
|
||||||
newServers: ServersMap;
|
newServers: ServersMap;
|
||||||
|
@ -37,9 +35,9 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||||
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
||||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||||
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
[EDIT_SERVER]: (state, { serverId, serverData }: any) => (
|
||||||
? state
|
!state[serverId] ? state : assoc(serverId, { ...state[serverId], ...serverData }, state)
|
||||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
),
|
||||||
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
|
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
|
||||||
if (!state[serverId]) {
|
if (!state[serverId]) {
|
||||||
return state;
|
return state;
|
||||||
|
@ -50,7 +48,7 @@ export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction
|
||||||
}
|
}
|
||||||
|
|
||||||
return fromPairs(
|
return fromPairs(
|
||||||
toPairs(state).map(([ evaluatedServerId, server ]) => [
|
toPairs(state).map(([evaluatedServerId, server]) => [
|
||||||
evaluatedServerId,
|
evaluatedServerId,
|
||||||
{ ...server, autoConnect: evaluatedServerId === serverId },
|
{ ...server, autoConnect: evaluatedServerId === serverId },
|
||||||
]),
|
]),
|
||||||
|
@ -66,7 +64,7 @@ export const createServers = pipe(
|
||||||
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
|
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const createServer = (server: ServerWithId) => createServers([ server ]);
|
export const createServer = (server: ServerWithId) => createServers([server]);
|
||||||
|
|
||||||
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
|
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
|
||||||
type: EDIT_SERVER,
|
type: EDIT_SERVER,
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { values } from 'ramda';
|
import { values } from 'ramda';
|
||||||
import { CsvJson } from 'csvjson';
|
|
||||||
import LocalStorage from '../../utils/services/LocalStorage';
|
import LocalStorage from '../../utils/services/LocalStorage';
|
||||||
import { ServersMap, serverWithIdToServerData } from '../data';
|
import { ServersMap, serverWithIdToServerData } from '../data';
|
||||||
import { saveCsv } from '../../utils/helpers/files';
|
import { saveCsv } from '../../utils/helpers/files';
|
||||||
|
import { JsonToCsv } from '../../utils/helpers/csvjson';
|
||||||
|
|
||||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||||
|
|
||||||
|
@ -10,14 +10,14 @@ export default class ServersExporter {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly storage: LocalStorage,
|
private readonly storage: LocalStorage,
|
||||||
private readonly window: Window,
|
private readonly window: Window,
|
||||||
private readonly csvjson: CsvJson,
|
private readonly jsonToCsv: JsonToCsv,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public readonly exportServers = async () => {
|
public readonly exportServers = async () => {
|
||||||
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
|
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = this.csvjson.toCSV(servers, { headers: 'key' });
|
const csv = this.jsonToCsv(servers);
|
||||||
|
|
||||||
saveCsv(this.window, csv, SERVERS_FILENAME);
|
saveCsv(this.window, csv, SERVERS_FILENAME);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { CsvJson } from 'csvjson';
|
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
|
import { CsvToJson } from '../../utils/helpers/csvjson';
|
||||||
|
|
||||||
const validateServer = (server: any): server is ServerData =>
|
const validateServer = (server: any): server is ServerData =>
|
||||||
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
||||||
|
@ -8,7 +8,7 @@ const validateServers = (servers: any): servers is ServerData[] =>
|
||||||
Array.isArray(servers) && servers.every(validateServer);
|
Array.isArray(servers) && servers.every(validateServer);
|
||||||
|
|
||||||
export class ServersImporter {
|
export class ServersImporter {
|
||||||
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
public constructor(private readonly csvToJson: CsvToJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||||
|
|
||||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
@ -18,19 +18,19 @@ export class ServersImporter {
|
||||||
const reader = this.fileReaderFactory();
|
const reader = this.fileReaderFactory();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
reader.addEventListener('loadend', async (e: ProgressEvent<FileReader>) => {
|
||||||
try {
|
try {
|
||||||
// TODO Read as stream, otherwise, if the file is too big, this will block the browser tab
|
// TODO Read as stream, otherwise, if the file is too big, this will block the browser tab
|
||||||
const content = e.target?.result?.toString() ?? '';
|
const content = e.target?.result?.toString() ?? '';
|
||||||
const servers = this.csvJson.toObject(content);
|
const servers = await this.csvToJson(content);
|
||||||
|
|
||||||
if (!validateServers(servers)) {
|
if (!validateServers(servers)) {
|
||||||
throw new Error('Provided file does not have the right format.');
|
throw new Error('Provided file does not have the right format.');
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(servers);
|
resolve(servers);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
reject(e);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import csvjson from 'csvjson';
|
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import CreateServer from '../CreateServer';
|
import CreateServer from '../CreateServer';
|
||||||
import ServersDropdown from '../ServersDropdown';
|
import ServersDropdown from '../ServersDropdown';
|
||||||
|
@ -9,7 +8,6 @@ import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||||
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||||
import { fetchServers } from '../reducers/remoteServers';
|
import { fetchServers } from '../reducers/remoteServers';
|
||||||
import ForServerVersion from '../helpers/ForServerVersion';
|
|
||||||
import { ServerError } from '../helpers/ServerError';
|
import { ServerError } from '../helpers/ServerError';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||||
|
@ -31,48 +29,44 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
'ManageServersRow',
|
'ManageServersRow',
|
||||||
);
|
);
|
||||||
bottle.decorator('ManageServers', withoutSelectedServer);
|
bottle.decorator('ManageServers', withoutSelectedServer);
|
||||||
bottle.decorator('ManageServers', connect([ 'selectedServer', 'servers' ], [ 'resetSelectedServer' ]));
|
bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
||||||
|
|
||||||
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
|
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
|
||||||
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ]));
|
bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
|
||||||
|
|
||||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||||
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
|
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServer', 'resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer', 'resetSelectedServer' ]));
|
bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
bottle.decorator('ServersDropdown', connect(['servers', 'selectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||||
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
|
bottle.decorator('DeleteServerModal', connect(null, ['deleteServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
||||||
|
|
||||||
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||||
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
|
bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers']));
|
||||||
|
|
||||||
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
|
||||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
||||||
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
|
bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl', 'ForServerVersion');
|
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
|
||||||
bottle.decorator('Overview', connect(
|
bottle.decorator('Overview', connect(
|
||||||
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview' ],
|
['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'],
|
||||||
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview' ],
|
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
|
||||||
));
|
));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.constant('csvjson', csvjson);
|
|
||||||
bottle.constant('fileReaderFactory', () => new FileReader());
|
bottle.constant('fileReaderFactory', () => new FileReader());
|
||||||
bottle.service('ServersImporter', ServersImporter, 'csvjson', 'fileReaderFactory');
|
bottle.service('ServersImporter', ServersImporter, 'csvToJson', 'fileReaderFactory');
|
||||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
|
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
/* eslint-disable no-restricted-globals */
|
|
||||||
|
|
||||||
// This service worker can be customized!
|
// This service worker can be customized!
|
||||||
// See https://developers.google.com/web/tools/workbox/modules
|
// See https://developers.google.com/web/tools/workbox/modules
|
||||||
|
|
|
@ -12,7 +12,7 @@ interface RealTimeUpdatesProps {
|
||||||
setRealTimeUpdatesInterval: (interval: number) => void;
|
setRealTimeUpdatesInterval: (interval: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
const intervalValue = (interval?: number) => (!interval ? '' : `${interval}`);
|
||||||
|
|
||||||
const RealTimeUpdatesSettings = (
|
const RealTimeUpdatesSettings = (
|
||||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||||
|
|
|
@ -25,9 +25,9 @@ const Settings = (
|
||||||
</NavPills>
|
</NavPills>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="general" element={<SettingsSections items={[ <UserInterface key="one" />, <RealTimeUpdates key="two" /> ]} />} />
|
<Route path="general" element={<SettingsSections items={[<UserInterface />, <RealTimeUpdates />]} />} />
|
||||||
<Route path="short-urls" element={<SettingsSections items={[ <ShortUrlCreation key="one" />, <ShortUrlsList key="two" /> ]} />} />
|
<Route path="short-urls" element={<SettingsSections items={[<ShortUrlCreation />, <ShortUrlsList />]} />} />
|
||||||
<Route path="other-items" element={<SettingsSections items={[ <Tags key="one" />, <Visits key="two" /> ]} />} />
|
<Route path="other-items" element={<SettingsSections items={[<Tags />, <Visits />]} />} />
|
||||||
<Route path="*" element={<Navigate replace to="general" />} />
|
<Route path="*" element={<Navigate replace to="general" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|
|
@ -13,11 +13,12 @@ interface ShortUrlCreationProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||||
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
|
(tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input');
|
||||||
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
|
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode => (
|
||||||
tagFilteringMode === 'includes'
|
tagFilteringMode === 'includes'
|
||||||
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
|
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
|
||||||
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
|
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>
|
||||||
|
);
|
||||||
|
|
||||||
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||||
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ShlinkState } from '../../container/types';
|
import { ShlinkState } from '../../container/types';
|
||||||
|
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
|
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
|
||||||
if (!state.settings) {
|
if (!state.settings) {
|
||||||
return state;
|
return state;
|
||||||
|
|
|
@ -31,28 +31,28 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
'TagsSettings',
|
'TagsSettings',
|
||||||
);
|
);
|
||||||
bottle.decorator('Settings', withoutSelectedServer);
|
bottle.decorator('Settings', withoutSelectedServer);
|
||||||
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
|
bottle.decorator('Settings', connect(null, ['resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
|
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
|
||||||
bottle.decorator(
|
bottle.decorator(
|
||||||
'RealTimeUpdatesSettings',
|
'RealTimeUpdatesSettings',
|
||||||
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
|
connect(['settings'], ['toggleRealTimeUpdates', 'setRealTimeUpdatesInterval']),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
|
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
|
||||||
bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
|
bottle.decorator('ShortUrlCreationSettings', connect(['settings'], ['setShortUrlCreationSettings']));
|
||||||
|
|
||||||
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
|
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
|
||||||
bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ]));
|
bottle.decorator('UserInterfaceSettings', connect(['settings'], ['setUiSettings']));
|
||||||
|
|
||||||
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
|
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
|
||||||
bottle.decorator('VisitsSettings', connect([ 'settings' ], [ 'setVisitsSettings' ]));
|
bottle.decorator('VisitsSettings', connect(['settings'], ['setVisitsSettings']));
|
||||||
|
|
||||||
bottle.serviceFactory('TagsSettings', () => TagsSettings);
|
bottle.serviceFactory('TagsSettings', () => TagsSettings);
|
||||||
bottle.decorator('TagsSettings', connect([ 'settings' ], [ 'setTagsSettings' ]));
|
bottle.decorator('TagsSettings', connect(['settings'], ['setTagsSettings']));
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
|
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
|
||||||
bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ]));
|
bottle.decorator('ShortUrlsListSettings', connect(['settings'], ['setShortUrlsListSettings']));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
||||||
|
|
|
@ -41,7 +41,7 @@ const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResul
|
||||||
basicMode = false,
|
basicMode = false,
|
||||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
settings: { shortUrlCreation: shortUrlCreationSettings },
|
||||||
}: CreateShortUrlConnectProps) => {
|
}: CreateShortUrlConnectProps) => {
|
||||||
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]);
|
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -63,9 +63,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||||
const initialState = useMemo(
|
const initialState = useMemo(
|
||||||
() => getInitialState(shortUrl, shortUrlCreationSettings),
|
() => getInitialState(shortUrl, shortUrlCreationSettings),
|
||||||
[ shortUrl, shortUrlCreationSettings ],
|
[shortUrl, shortUrlCreationSettings],
|
||||||
);
|
);
|
||||||
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
|
const [savingSucceeded,, isSuccessful, isNotSuccessful] = useToggle();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
params.shortCode && getShortUrlDetail(params.shortCode, domain);
|
params.shortCode && getShortUrlDetail(params.shortCode, domain);
|
||||||
|
|
|
@ -2,10 +2,9 @@ import { FC, useEffect, useState } from 'react';
|
||||||
import { InputType } from 'reactstrap/types/lib/Input';
|
import { InputType } from 'reactstrap/types/lib/Input';
|
||||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||||
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||||
import { supportsCrawlableVisits, supportsForwardQuery, supportsShortUrlTitle } from '../utils/helpers/features';
|
import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
|
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
|
||||||
import Checkbox from '../utils/Checkbox';
|
import Checkbox from '../utils/Checkbox';
|
||||||
|
@ -13,7 +12,7 @@ import { SelectedServer } from '../servers/data';
|
||||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
import { DomainSelectorProps } from '../domains/DomainSelector';
|
import { DomainSelectorProps } from '../domains/DomainSelector';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
|
||||||
import { ShortUrlData } from './data';
|
import { ShortUrlData } from './data';
|
||||||
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
||||||
import './ShortUrlForm.scss';
|
import './ShortUrlForm.scss';
|
||||||
|
@ -32,14 +31,13 @@ export interface ShortUrlFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
|
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
|
||||||
const dynamicColClasses = (flag: boolean) => ({ 'col-sm-6': flag, 'col-sm-12': !flag });
|
|
||||||
|
|
||||||
export const ShortUrlForm = (
|
export const ShortUrlForm = (
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
DomainSelector: FC<DomainSelectorProps>,
|
DomainSelector: FC<DomainSelectorProps>,
|
||||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
||||||
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
const [shortUrlData, setShortUrlData] = useState(initialState);
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
const isBasicMode = mode === 'create-basic';
|
const isBasicMode = mode === 'create-basic';
|
||||||
const hadTitleOriginally = hasValue(initialState.title);
|
const hadTitleOriginally = hasValue(initialState.title);
|
||||||
|
@ -48,9 +46,9 @@ export const ShortUrlForm = (
|
||||||
const resolveNewTitle = (): OptionalString => {
|
const resolveNewTitle = (): OptionalString => {
|
||||||
const hasNewTitle = hasValue(shortUrlData.title);
|
const hasNewTitle = hasValue(shortUrlData.title);
|
||||||
const matcher = cond<never, OptionalString>([
|
const matcher = cond<never, OptionalString>([
|
||||||
[ () => !hasNewTitle && !hadTitleOriginally, () => undefined ],
|
[() => !hasNewTitle && !hadTitleOriginally, () => undefined],
|
||||||
[ () => !hasNewTitle && hadTitleOriginally, () => null ],
|
[() => !hasNewTitle && hadTitleOriginally, () => null],
|
||||||
[ T, () => shortUrlData.title ],
|
[T, () => shortUrlData.title],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return matcher();
|
return matcher();
|
||||||
|
@ -65,7 +63,7 @@ export const ShortUrlForm = (
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShortUrlData(initialState);
|
setShortUrlData(initialState);
|
||||||
}, [ initialState ]);
|
}, [initialState]);
|
||||||
|
|
||||||
const renderOptionalInput = (
|
const renderOptionalInput = (
|
||||||
id: NonDateFields,
|
id: NonDateFields,
|
||||||
|
@ -108,20 +106,16 @@ export const ShortUrlForm = (
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<Row>
|
<Row>
|
||||||
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
||||||
<div className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
|
<div className={isBasicMode ? 'col-lg-6 mb-3' : 'col-12'}>
|
||||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
|
||||||
const showCustomizeCard = supportsTitle || !isEdit;
|
|
||||||
const limitAccessCardClasses = classNames('mb-3', dynamicColClasses(showCustomizeCard));
|
|
||||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||||
const showForwardQueryControl = supportsForwardQuery(selectedServer);
|
const showForwardQueryControl = supportsForwardQuery(selectedServer);
|
||||||
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
|
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
|
||||||
const extraChecksCardClasses = classNames('mb-3', dynamicColClasses(showBehaviorCard));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="short-url-form" onSubmit={submit}>
|
<form className="short-url-form" onSubmit={submit}>
|
||||||
|
@ -133,36 +127,34 @@ export const ShortUrlForm = (
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
{showCustomizeCard && (
|
<div className="col-sm-6 mb-3">
|
||||||
<div className="col-sm-6 mb-3">
|
<SimpleCard title="Customize the short URL">
|
||||||
<SimpleCard title="Customize the short URL">
|
{renderOptionalInput('title', 'Title')}
|
||||||
{supportsTitle && renderOptionalInput('title', 'Title')}
|
{!isEdit && (
|
||||||
{!isEdit && (
|
<>
|
||||||
<>
|
<Row>
|
||||||
<Row>
|
<div className="col-lg-6">
|
||||||
<div className="col-lg-6">
|
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
||||||
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
disabled: hasValue(shortUrlData.shortCodeLength),
|
||||||
disabled: hasValue(shortUrlData.shortCodeLength),
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
<div className="col-lg-6">
|
||||||
<div className="col-lg-6">
|
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
min: 4,
|
||||||
min: 4,
|
disabled: hasValue(shortUrlData.customSlug),
|
||||||
disabled: hasValue(shortUrlData.customSlug),
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</Row>
|
||||||
</Row>
|
<DomainSelector
|
||||||
<DomainSelector
|
value={shortUrlData.domain}
|
||||||
value={shortUrlData.domain}
|
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
/>
|
||||||
/>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</SimpleCard>
|
||||||
</SimpleCard>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={limitAccessCardClasses}>
|
<div className="col-sm-6 mb-3">
|
||||||
<SimpleCard title="Limit access to the short URL">
|
<SimpleCard title="Limit access to the short URL">
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
@ -174,7 +166,7 @@ export const ShortUrlForm = (
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
<div className={extraChecksCardClasses}>
|
<div className="col-sm-6 mb-3">
|
||||||
<SimpleCard title="Extra checks">
|
<SimpleCard title="Extra checks">
|
||||||
<ShortUrlFormCheckboxGroup
|
<ShortUrlFormCheckboxGroup
|
||||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
.short-urls-filtering-bar__tags-icon {
|
.short-urls-filtering-bar__tags-icon {
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,22 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { Row } from 'reactstrap';
|
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import SearchField from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch';
|
|
||||||
import { OrderDir } from '../utils/helpers/ordering';
|
import { OrderDir } from '../utils/helpers/ordering';
|
||||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { useShortUrlsQuery } from './helpers/hooks';
|
import { useShortUrlsQuery } from './helpers/hooks';
|
||||||
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||||
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||||
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
import './ShortUrlsFilteringBar.scss';
|
import './ShortUrlsFilteringBar.scss';
|
||||||
|
|
||||||
export interface ShortUrlsFilteringProps {
|
export interface ShortUrlsFilteringProps {
|
||||||
|
@ -29,45 +27,50 @@ export interface ShortUrlsFilteringProps {
|
||||||
shortUrlsAmount?: number;
|
shortUrlsAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
|
||||||
|
|
||||||
const ShortUrlsFilteringBar = (
|
export const ShortUrlsFilteringBar = (
|
||||||
colorGenerator: ColorGenerator,
|
|
||||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||||
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
|
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
|
||||||
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery();
|
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery();
|
||||||
const setDates = pipe(
|
const setDates = pipe(
|
||||||
({ startDate, endDate }: DateRange) => ({
|
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||||
startDate: formatIsoDate(startDate) ?? undefined,
|
startDate: formatIsoDate(theStartDate) ?? undefined,
|
||||||
endDate: formatIsoDate(endDate) ?? undefined,
|
endDate: formatIsoDate(theEndDate) ?? undefined,
|
||||||
}),
|
}),
|
||||||
toFirstPage,
|
toFirstPage,
|
||||||
);
|
);
|
||||||
const setSearch = pipe(
|
const setSearch = pipe(
|
||||||
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
|
(searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm),
|
||||||
(search) => toFirstPage({ search }),
|
(searchTerm) => toFirstPage({ search: searchTerm }),
|
||||||
);
|
|
||||||
const removeTag = pipe(
|
|
||||||
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
|
|
||||||
(updateTags) => toFirstPage({ tags: updateTags }),
|
|
||||||
);
|
);
|
||||||
|
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
|
||||||
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
|
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
|
||||||
const toggleTagsMode = pipe(
|
const toggleTagsMode = pipe(
|
||||||
() => tagsMode === 'any' ? 'all' : 'any',
|
() => (tagsMode === 'any' ? 'all' : 'any'),
|
||||||
(tagsMode) => toFirstPage({ tagsMode }),
|
(mode) => toFirstPage({ tagsMode: mode }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('short-urls-filtering-bar-container', className)}>
|
<div className={classNames('short-urls-filtering-bar-container', className)}>
|
||||||
<SearchField initialValue={search} onChange={setSearch} />
|
<SearchField initialValue={search} onChange={setSearch} />
|
||||||
|
|
||||||
<Row className="flex-column-reverse flex-lg-row">
|
<InputGroup className="mt-3">
|
||||||
<div className="col-lg-4 col-xl-6 mt-3">
|
<TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
|
||||||
<ExportShortUrlsBtn amount={shortUrlsAmount} />
|
{canChangeTagsMode && tags.length > 1 && (
|
||||||
</div>
|
<>
|
||||||
<div className="col-12 d-block d-lg-none mt-3">
|
<Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode">
|
||||||
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
|
||||||
</div>
|
</Button>
|
||||||
|
<UncontrolledTooltip target="tagsModeBtn" placement="left">
|
||||||
|
{tagsMode === 'all' ? 'With all the tags.' : 'With any of the tags.'}
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<Row className="flex-lg-row-reverse">
|
||||||
<div className="col-lg-8 col-xl-6 mt-3">
|
<div className="col-lg-8 col-xl-6 mt-3">
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
|
@ -78,28 +81,18 @@ const ShortUrlsFilteringBar = (
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-6 col-lg-4 col-xl-6 mt-3">
|
||||||
|
<ExportShortUrlsBtn amount={shortUrlsAmount} />
|
||||||
|
</div>
|
||||||
|
<div className="col-6 d-lg-none mt-3">
|
||||||
|
<OrderingDropdown
|
||||||
|
prefixed={false}
|
||||||
|
items={SHORT_URLS_ORDERABLE_FIELDS}
|
||||||
|
order={order}
|
||||||
|
onChange={handleOrderBy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{tags.length > 0 && (
|
|
||||||
<h4 className="mt-3">
|
|
||||||
{canChangeTagsMode && tags.length > 1 && (
|
|
||||||
<div className="float-end ms-2 mt-1">
|
|
||||||
<TooltipToggleSwitch
|
|
||||||
checked={tagsMode === 'all'}
|
|
||||||
tooltip={{ placement: 'left' }}
|
|
||||||
onChange={toggleTagsMode}
|
|
||||||
>
|
|
||||||
{tagsMode === 'all' ? 'Short URLs including all tags.' : 'Short URLs including any tag.'}
|
|
||||||
</TooltipToggleSwitch>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon me-1" />
|
|
||||||
{tags.map((tag) =>
|
|
||||||
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortUrlsFilteringBar;
|
|
||||||
|
|
|
@ -30,8 +30,8 @@ const ShortUrlsList = (
|
||||||
const serverId = getServerId(selectedServer);
|
const serverId = getServerId(selectedServer);
|
||||||
const { page } = useParams();
|
const { page } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery();
|
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage] = useShortUrlsQuery();
|
||||||
const [ actualOrderBy, setActualOrderBy ] = useState(
|
const [actualOrderBy, setActualOrderBy] = useState(
|
||||||
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
||||||
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
||||||
);
|
);
|
||||||
|
@ -45,7 +45,7 @@ const ShortUrlsList = (
|
||||||
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
||||||
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
||||||
const addTag = pipe(
|
const addTag = pipe(
|
||||||
(newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
|
(newTag: string) => [...new Set([...tags, newTag])],
|
||||||
(updatedTags) => toFirstPage({ tags: updatedTags }),
|
(updatedTags) => toFirstPage({ tags: updatedTags }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ const ShortUrlsList = (
|
||||||
orderBy: actualOrderBy,
|
orderBy: actualOrderBy,
|
||||||
tagsMode,
|
tagsMode,
|
||||||
});
|
});
|
||||||
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
|
}, [page, search, tags, startDate, endDate, actualOrderBy, tagsMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -82,6 +82,6 @@ const ShortUrlsList = (
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits ]);
|
}, () => [Topics.visits]);
|
||||||
|
|
||||||
export default ShortUrlsList;
|
export default ShortUrlsList;
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { FC, ReactNode } from 'react';
|
||||||
import { isEmpty } from 'ramda';
|
import { isEmpty } from 'ramda';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { supportsShortUrlTitle } from '../utils/helpers/features';
|
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
||||||
import { ShortUrlsOrderableFields } from './data';
|
import { ShortUrlsOrderableFields } from './data';
|
||||||
|
@ -29,7 +28,6 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
||||||
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
|
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
|
||||||
const tableClasses = classNames('table table-hover responsive-table', className);
|
const tableClasses = classNames('table table-hover responsive-table', className);
|
||||||
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
|
||||||
|
|
||||||
const renderShortUrls = () => {
|
const renderShortUrls = () => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -70,21 +68,15 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
||||||
Short URL {renderOrderIcon?.('shortCode')}
|
Short URL {renderOrderIcon?.('shortCode')}
|
||||||
</th>
|
</th>
|
||||||
{!supportsTitle && (
|
<th className="short-urls-table__header-cell">
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
||||||
Long URL {renderOrderIcon?.('longUrl')}
|
Title {renderOrderIcon?.('title')}
|
||||||
</th>
|
</span>
|
||||||
) || (
|
/
|
||||||
<th className="short-urls-table__header-cell">
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
|
||||||
Title {renderOrderIcon?.('title')}
|
</span>
|
||||||
</span>
|
</th>
|
||||||
/
|
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
|
||||||
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th className="short-urls-table__header-cell">Tags</th>
|
<th className="short-urls-table__header-cell">Tags</th>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||||
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||||
|
|
|
@ -36,8 +36,8 @@ const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void })
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
const UseExistingIfFoundInfoIcon = () => {
|
export const UseExistingIfFoundInfoIcon = () => {
|
||||||
const [ isModalOpen, toggleModal ] = useToggle();
|
const [isModalOpen, toggleModal] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -48,5 +48,3 @@ const UseExistingIfFoundInfoIcon = () => {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UseExistingIfFoundInfoIcon;
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
||||||
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||||
) => {
|
) => {
|
||||||
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
const [showCopyTooltip, setShowCopyTooltip] = useStateFlagTimeout();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetCreateShortUrl();
|
resetCreateShortUrl();
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue