Merge pull request #786 from shlinkio/develop

Release 3.9.0
This commit is contained in:
Alejandro Celaya 2022-12-31 17:07:56 +01:00 committed by GitHub
commit 5db0326350
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
121 changed files with 5881 additions and 24714 deletions

View file

@ -11,7 +11,7 @@ jobs:
ci:
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
with:
node-version: 16.15
node-version: 18.12
with-mutation-tests: true
publish-coverage: true
force-install: true

View file

@ -16,12 +16,11 @@ jobs:
- name: Use node.js
uses: actions/setup-node@v1
with:
node-version: 16.15
node-version: 18.12
- name: Build
run: |
npm ci --force && \
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
rm src/service-worker.ts && \
npm run build
- name: Deploy preview
uses: shlinkio/deploy-preview-action@v1.0.1

View file

@ -14,7 +14,7 @@ jobs:
- name: Use node.js
uses: actions/setup-node@v1
with:
node-version: 16.15
node-version: 18.12
- name: Generate release assets
run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets

View file

@ -4,6 +4,31 @@ 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).
## [3.9.0] - 2022-12-31
### Added
* [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc.
* [#764](https://github.com/shlinkio/shlink-web-client/issues/764) Added support to exclude visits from visits on short URLs list when consuming Shlink 3.4.0.
This feature also comes with a new setting to disable visits from bots by default, both on short URLs lists and visits sections.
* [#760](https://github.com/shlinkio/shlink-web-client/issues/760) Added support to exclude short URLs which have reached the maximum amount of visits, or are valid until a date in the past.
### Changed
* [#753](https://github.com/shlinkio/shlink-web-client/issues/753) Migrated from react-scripts/webpack to vite.
* [#770](https://github.com/shlinkio/shlink-web-client/issues/770) Updated to latest dependencies.
* [#741](https://github.com/shlinkio/shlink-web-client/issues/741) Improved `visitsAsyncThunk`, making it wrap pending/fulfilled/rejected actions, as well as custom ones, in a type-safe way.
### Deprecated
* *Nothing*
### Removed
* [#736](https://github.com/shlinkio/shlink-web-client/issues/736) Removed cards mode in tags. Only table mode is supported now.
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
### Fixed
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
## [3.8.2] - 2022-12-17
### Added
* *Nothing*

View file

@ -1,10 +1,10 @@
FROM node:16.15-alpine as node
FROM node:18.12-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && npm ci --force && NODE_ENV=production npm run build
RUN cd /shlink-web-client && npm ci --force && npm run build
FROM nginx:1.21-alpine
FROM nginx:1.23-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf

View file

@ -1,6 +1,6 @@
# shlink-web-client
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink-web-client/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
[![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink-web-client/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink-web-client/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink-web-client)
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)

View file

@ -1,11 +1,9 @@
module.exports = {
presets: [
[
'react-app',
{
runtime: 'automatic',
typescript: true,
},
],
['@babel/preset-env', {
targets: { esmodules: true }
}],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
};

View file

@ -1,5 +1,6 @@
import '@testing-library/jest-dom';
import 'jest-canvas-mock';
import 'chart.js/auto';
import ResizeObserver from 'resize-observer-polyfill';
import { setAutoFreeze } from 'immer';

View file

@ -3,8 +3,8 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:16.15-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
image: node:18.12-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install --force && npm run start"
volumes:
- ./:/home/shlink/www
ports:

90
index.html Normal file
View file

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#4696e5">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
<!-- FavIcon itself -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="icon" type="image/gif" href="/favicon.gif">
<!-- Apple Touch -->
<link rel="apple-touch-icon" sizes="16x16" href="/icons/icon-16x16.png">
<link rel="apple-touch-icon" sizes="24x24" href="/icons/icon-24x24.png">
<link rel="apple-touch-icon" sizes="32x32" href="/icons/icon-32x32.png">
<link rel="apple-touch-icon" sizes="40x40" href="/icons/icon-40x40.png">
<link rel="apple-touch-icon" sizes="48x48" href="/icons/icon-48x48.png">
<link rel="apple-touch-icon" sizes="60x60" href="/icons/icon-60x60.png">
<link rel="apple-touch-icon" sizes="64x64" href="/icons/icon-64x64.png">
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="/icons/icon-76x76.png">
<link rel="apple-touch-icon" sizes="96x96" href="/icons/icon-96x96.png">
<link rel="apple-touch-icon" sizes="114x114" href="/icons/icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/icon-120x120.png">
<link rel="apple-touch-icon" sizes="128x128" href="/icons/icon-128x128.png">
<link rel="apple-touch-icon" sizes="144x144" href="/icons/icon-144x144.png">
<link rel="apple-touch-icon" sizes="150x150" href="/icons/icon-150x150.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png">
<link rel="apple-touch-icon" sizes="160x160" href="/icons/icon-160x160.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png">
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192x192.png">
<link rel="apple-touch-icon" sizes="196x196" href="/icons/icon-196x196.png">
<link rel="apple-touch-icon" sizes="228x228" href="/icons/icon-228x228.png">
<link rel="apple-touch-icon" sizes="256x256" href="/icons/icon-256x256.png">
<link rel="apple-touch-icon" sizes="310x310" href="/icons/icon-310x310.png">
<link rel="apple-touch-icon" sizes="384x384" href="/icons/icon-384x384.png">
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png">
<link rel="apple-touch-icon" sizes="1024x1024" href="/icons/icon-1024x1024.png">
<!-- Normal -->
<link rel="icon" type="image/png" sizes="1024x1024" href="/icons/icon-1024x1024.png">
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512x512.png">
<link rel="icon" type="image/png" sizes="384x384" href="/icons/icon-384x384.png">
<link rel="icon" type="image/png" sizes="310x310" href="/icons/icon-310x310.png">
<link rel="icon" type="image/png" sizes="256x256" href="/icons/icon-256x256.png">
<link rel="icon" type="image/png" sizes="228x228" href="/icons/icon-228x228.png">
<link rel="icon" type="image/png" sizes="196x196" href="/icons/icon-196x196.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192x192.png">
<link rel="icon" type="image/png" sizes="180x180" href="/icons/icon-180x180.png">
<link rel="icon" type="image/png" sizes="167x167" href="/icons/icon-167x167.png">
<link rel="icon" type="image/png" sizes="160x160" href="/icons/icon-160x160.png">
<link rel="icon" type="image/png" sizes="152x152" href="/icons/icon-152x152.png">
<link rel="icon" type="image/png" sizes="150x150" href="/icons/icon-150x150.png">
<link rel="icon" type="image/png" sizes="144x144" href="/icons/icon-144x144.png">
<link rel="icon" type="image/png" sizes="128x128" href="/icons/icon-128x128.png">
<link rel="icon" type="image/png" sizes="120x120" href="/icons/icon-120x120.png">
<link rel="icon" type="image/png" sizes="114x114" href="/icons/icon-114x114.png">
<link rel="icon" type="image/png" sizes="96x96" href="/icons/icon-96x96.png">
<link rel="icon" type="image/png" sizes="76x76" href="/icons/icon-76x76.png">
<link rel="icon" type="image/png" sizes="72x72" href="/icons/icon-72x72.png">
<link rel="icon" type="image/png" sizes="64x64" href="/icons/icon-64x64.png">
<link rel="icon" type="image/png" sizes="60x60" href="/icons/icon-60x60.png">
<link rel="icon" type="image/png" sizes="48x48" href="/icons/icon-48x48.png">
<link rel="icon" type="image/png" sizes="40x40" href="/icons/icon-40x40.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32x32.png">
<link rel="icon" type="image/png" sizes="24x24" href="/icons/icon-24x24.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16x16.png">
<!-- MS -->
<meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
<meta name="msapplication-square70x70logo" content="/icons/icon-70x70.png">
<meta name="msapplication-square144x144logo" content="/icons/icon-144x144.png">
<meta name="msapplication-square150x150logo" content="/icons/icon-150x150.png">
<meta name="msapplication-square310x310logo" content="/icons/icon-310x310.png">
<title>Shlink — The URL shortener</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

145
manifest.ts Normal file
View file

@ -0,0 +1,145 @@
export const manifest = {
short_name: 'Shlink',
name: 'Shlink',
start_url: '/',
display: 'standalone',
theme_color: '#4696e5',
background_color: '#4696e5',
icons: [
{
src: './icons/icon-16x16.png',
type: 'image/png',
sizes: '16x16',
},
{
src: './icons/icon-24x24.png',
type: 'image/png',
sizes: '24x24',
},
{
src: './icons/icon-32x32.png',
type: 'image/png',
sizes: '32x32',
},
{
src: './icons/icon-40x40.png',
type: 'image/png',
sizes: '40x40',
},
{
src: './icons/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
},
{
src: './icons/icon-60x60.png',
type: 'image/png',
sizes: '60x60',
},
{
src: './icons/icon-64x64.png',
type: 'image/png',
sizes: '64x64',
},
{
src: './icons/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
},
{
src: './icons/icon-76x76.png',
type: 'image/png',
sizes: '76x76',
},
{
src: './icons/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
},
{
src: './icons/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
},
{
src: './icons/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
},
{
src: './icons/icon-128x128.png',
type: 'image/png',
sizes: '128x128',
},
{
src: './icons/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
src: './icons/icon-150x150.png',
type: 'image/png',
sizes: '150x150',
},
{
src: './icons/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
},
{
src: './icons/icon-160x160.png',
type: 'image/png',
sizes: '160x160',
},
{
src: './icons/icon-167x167.png',
type: 'image/png',
sizes: '167x167',
},
{
src: './icons/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
},
{
src: './icons/icon-192x192.png',
type: 'image/png',
sizes: '192x192',
},
{
src: './icons/icon-196x196.png',
type: 'image/png',
sizes: '196x196',
},
{
src: './icons/icon-228x228.png',
type: 'image/png',
sizes: '228x228',
},
{
src: './icons/icon-256x256.png',
type: 'image/png',
sizes: '256x256',
},
{
src: './icons/icon-310x310.png',
type: 'image/png',
sizes: '310x310',
},
{
src: './icons/icon-384x384.png',
type: 'image/png',
sizes: '384x384',
},
{
src: './icons/icon-512x512.png',
type: 'image/png',
sizes: '512x512',
},
{
src: './icons/icon-1024x1024.png',
type: 'image/png',
sizes: '1024x1024',
},
],
};

27869
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,8 +12,8 @@
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix",
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.mjs",
"start": "vite serve --host=0.0.0.0",
"build": "tsc --noEmit && vite build && node scripts/replace-version.mjs",
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
"build:serve": "serve -p 5000 ./build",
"test": "jest --env=jsdom --colors",
@ -24,43 +24,45 @@
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.2.0",
"@fortawesome/fontawesome-svg-core": "^6.2.0",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@fortawesome/fontawesome-free": "^6.2.1",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-regular-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "^1.9.0",
"bootstrap": "^5.2.2",
"@json2csv/plainjs": "^6.1.2",
"@reduxjs/toolkit": "^1.9.1",
"bootstrap": "^5.2.3",
"bottlejs": "^2.0.1",
"bowser": "^2.11.0",
"chart.js": "^3.9.1",
"classnames": "^2.3.1",
"compare-versions": "^5.0.1",
"chart.js": "^4.1.1",
"classnames": "^2.3.2",
"compare-versions": "^5.0.3",
"csvtojson": "^2.0.10",
"date-fns": "^2.29.3",
"event-source-polyfill": "^1.0.31",
"history": "^5.3.0",
"json2csv": "^5.0.7",
"leaflet": "^1.9.2",
"leaflet": "^1.9.3",
"qs": "^6.11.0",
"ramda": "^0.27.2",
"react": "^18.2.0",
"react-chartjs-2": "^4.3.1",
"react-chartjs-2": "^5.1.0",
"react-colorful": "^5.6.1",
"react-copy-to-clipboard": "^5.1.0",
"react-datepicker": "^4.8.0",
"react-dom": "^18.2.0",
"react-external-link": "^2.0.0",
"react-leaflet": "^4.1.0",
"react-redux": "^8.0.4",
"react-router-dom": "^6.4.1",
"react-leaflet": "^4.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.6.1",
"react-swipeable": "^7.0.0",
"react-tag-autocomplete": "^6.3.0",
"reactstrap": "^9.1.4",
"reactstrap": "^9.1.5",
"redux": "^4.2.0",
"redux-localstorage-simple": "^2.5.1",
"redux-thunk": "^2.4.1",
"stream": "^0.0.2",
"redux-thunk": "^2.4.2",
"uuid": "^8.3.2",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
@ -71,41 +73,42 @@
"devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
"@stryker-mutator/core": "^6.2.2",
"@stryker-mutator/jest-runner": "^6.2.2",
"@stryker-mutator/typescript-checker": "^6.2.2",
"@stryker-mutator/core": "^6.3.1",
"@stryker-mutator/jest-runner": "^6.3.1",
"@stryker-mutator/typescript-checker": "^6.3.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.1.1",
"@types/jest": "^29.2.4",
"@types/json2csv": "^5.0.3",
"@types/leaflet": "^1.8.0",
"@types/leaflet": "^1.9.0",
"@types/qs": "^6.9.7",
"@types/ramda": "^0.28.15",
"@types/react": "^18.0.21",
"@types/react": "^18.0.26",
"@types/react-color": "^3.0.6",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-datepicker": "^4.4.2",
"@types/react-dom": "^18.0.6",
"@types/react-datepicker": "^4.8.0",
"@types/react-dom": "^18.0.10",
"@types/react-tag-autocomplete": "^6.3.0",
"@types/uuid": "^8.3.4",
"adm-zip": "^0.5.9",
"babel-jest": "^29.1.2",
"chalk": "^5.0.1",
"eslint": "^8.24.0",
"@vitejs/plugin-react": "^3.0.0",
"adm-zip": "^0.5.10",
"babel-jest": "^29.3.1",
"chalk": "^5.2.0",
"eslint": "^8.30.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.1.2",
"jest": "^29.3.1",
"jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^29.1.2",
"react-scripts": "^5.0.1",
"jest-environment-jsdom": "^29.3.1",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.55.0",
"serve": "^14.1.1",
"sass": "^1.57.1",
"serve": "^14.1.2",
"stryker-cli": "^1.0.2",
"stylelint": "^14.13.0",
"stylelint": "^14.16.0",
"ts-mockery": "^1.2.0",
"typescript": "^4.8.4",
"webpack": "^5.74.0"
"typescript": "^4.9.4",
"vite": "^4.0.3",
"vite-plugin-pwa": "^0.14.0"
},
"browserslist": [
">0.2%",

View file

@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#4696e5">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" crossorigin="use-credentials">
<!-- FavIcon itself -->
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon.svg" sizes="any">
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.png">
<link rel="icon" type="image/gif" href="%PUBLIC_URL%/favicon.gif">
<!-- Apple Touch -->
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
<link rel="apple-touch-icon" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
<link rel="apple-touch-icon" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
<link rel="apple-touch-icon" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
<link rel="apple-touch-icon" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
<link rel="apple-touch-icon" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
<link rel="apple-touch-icon" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
<link rel="apple-touch-icon" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
<link rel="apple-touch-icon" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
<link rel="apple-touch-icon" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
<link rel="apple-touch-icon" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
<link rel="apple-touch-icon" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
<link rel="apple-touch-icon" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
<link rel="apple-touch-icon" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
<link rel="apple-touch-icon" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
<link rel="apple-touch-icon" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
<link rel="apple-touch-icon" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
<link rel="apple-touch-icon" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
<link rel="apple-touch-icon" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
<!-- Normal -->
<link rel="icon" type="image/png" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
<link rel="icon" type="image/png" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
<link rel="icon" type="image/png" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
<link rel="icon" type="image/png" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
<link rel="icon" type="image/png" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
<link rel="icon" type="image/png" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
<link rel="icon" type="image/png" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
<link rel="icon" type="image/png" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
<link rel="icon" type="image/png" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
<link rel="icon" type="image/png" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
<link rel="icon" type="image/png" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
<link rel="icon" type="image/png" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
<link rel="icon" type="image/png" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
<link rel="icon" type="image/png" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
<link rel="icon" type="image/png" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
<link rel="icon" type="image/png" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
<link rel="icon" type="image/png" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
<link rel="icon" type="image/png" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
<link rel="icon" type="image/png" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
<link rel="icon" type="image/png" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
<link rel="icon" type="image/png" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
<link rel="icon" type="image/png" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
<link rel="icon" type="image/png" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
<!-- MS -->
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-144x144.png">
<meta name="msapplication-square70x70logo" content="%PUBLIC_URL%/icons/icon-70x70.png">
<meta name="msapplication-square144x144logo" content="%PUBLIC_URL%/icons/icon-144x144.png">
<meta name="msapplication-square150x150logo" content="%PUBLIC_URL%/icons/icon-150x150.png">
<meta name="msapplication-square310x310logo" content="%PUBLIC_URL%/icons/icon-310x310.png">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Shlink — The URL shortener</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -1,145 +0,0 @@
{
"short_name": "Shlink",
"name": "Shlink",
"start_url": "/",
"display": "standalone",
"theme_color": "#4696e5",
"background_color": "#4696e5",
"icons": [
{
"src": "./icons/icon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "./icons/icon-24x24.png",
"type": "image/png",
"sizes": "24x24"
},
{
"src": "./icons/icon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "./icons/icon-40x40.png",
"type": "image/png",
"sizes": "40x40"
},
{
"src": "./icons/icon-48x48.png",
"type": "image/png",
"sizes": "48x48"
},
{
"src": "./icons/icon-60x60.png",
"type": "image/png",
"sizes": "60x60"
},
{
"src": "./icons/icon-64x64.png",
"type": "image/png",
"sizes": "64x64"
},
{
"src": "./icons/icon-72x72.png",
"type": "image/png",
"sizes": "72x72"
},
{
"src": "./icons/icon-76x76.png",
"type": "image/png",
"sizes": "76x76"
},
{
"src": "./icons/icon-96x96.png",
"type": "image/png",
"sizes": "96x96"
},
{
"src": "./icons/icon-114x114.png",
"type": "image/png",
"sizes": "114x114"
},
{
"src": "./icons/icon-120x120.png",
"type": "image/png",
"sizes": "120x120"
},
{
"src": "./icons/icon-128x128.png",
"type": "image/png",
"sizes": "128x128"
},
{
"src": "./icons/icon-144x144.png",
"type": "image/png",
"sizes": "144x144"
},
{
"src": "./icons/icon-150x150.png",
"type": "image/png",
"sizes": "150x150"
},
{
"src": "./icons/icon-152x152.png",
"type": "image/png",
"sizes": "152x152"
},
{
"src": "./icons/icon-160x160.png",
"type": "image/png",
"sizes": "160x160"
},
{
"src": "./icons/icon-167x167.png",
"type": "image/png",
"sizes": "167x167"
},
{
"src": "./icons/icon-180x180.png",
"type": "image/png",
"sizes": "180x180"
},
{
"src": "./icons/icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "./icons/icon-196x196.png",
"type": "image/png",
"sizes": "196x196"
},
{
"src": "./icons/icon-228x228.png",
"type": "image/png",
"sizes": "228x228"
},
{
"src": "./icons/icon-256x256.png",
"type": "image/png",
"sizes": "256x256"
},
{
"src": "./icons/icon-310x310.png",
"type": "image/png",
"sizes": "310x310"
},
{
"src": "./icons/icon-384x384.png",
"type": "image/png",
"sizes": "384x384"
},
{
"src": "./icons/icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "./icons/icon-1024x1024.png",
"type": "image/png",
"sizes": "1024x1024"
}
]
}

View file

@ -1,11 +1,11 @@
import fs from 'fs';
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const staticJsFilesPath = './build/assets';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const isMainFile = (file) => file.startsWith('index-') && file.endsWith('.js');
const [mainJsFile] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);

View file

@ -1,3 +1,4 @@
// eslint-disable-next-line max-classes-per-file
declare module 'event-source-polyfill' {
declare class EventSourcePolyfill {
public onmessage?: ({ data }: { data: string }) => void;
@ -7,4 +8,10 @@ declare module 'event-source-polyfill' {
}
}
declare module '@json2csv/plainjs' {
export class Parser {
parse: <T>(data: T[]) => string;
}
}
declare module '*.png'

View file

@ -24,9 +24,14 @@ import { HttpClient } from '../../common/services/HttpClient';
const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`;
const rejectNilProps = reject(isNil);
const normalizeOrderByInParams = (
{ orderBy = {}, ...rest }: ShlinkShortUrlsListParams,
): ShlinkShortUrlsListNormalizedParams => ({ ...rest, orderBy: orderToString(orderBy) });
const normalizeListParams = (
{ orderBy = {}, excludeMaxVisitsReached, excludePastValidUntil, ...rest }: ShlinkShortUrlsListParams,
): ShlinkShortUrlsListNormalizedParams => ({
...rest,
excludeMaxVisitsReached: excludeMaxVisitsReached === true ? 'true' : undefined,
excludePastValidUntil: excludePastValidUntil === true ? 'true' : undefined,
orderBy: orderToString(orderBy),
});
export class ShlinkApiClient {
private apiVersion: 2 | 3;
@ -40,7 +45,7 @@ export class ShlinkApiClient {
}
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeListParams(params))
.then(({ shortUrls }) => shortUrls);
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {

View file

@ -1,6 +1,7 @@
import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
import { Order } from '../../utils/helpers/ordering';
export interface ShlinkShortUrlsResponse {
data: ShortUrl[];
@ -88,17 +89,26 @@ export interface ShlinkDomainsResponse {
export type TagsFilteringMode = 'all' | 'any';
type ShlinkShortUrlsOrderableFields = 'dateCreated' | 'shortCode' | 'longUrl' | 'title' | 'visits' | 'nonBotVisits';
export type ShlinkShortUrlsOrder = Order<ShlinkShortUrlsOrderableFields>;
export interface ShlinkShortUrlsListParams {
page?: string;
itemsPerPage?: number;
tags?: string[];
searchTerm?: string;
tags?: string[];
tagsMode?: TagsFilteringMode;
orderBy?: ShlinkShortUrlsOrder;
startDate?: string;
endDate?: string;
orderBy?: ShortUrlsOrder;
tagsMode?: TagsFilteringMode;
excludeMaxVisitsReached?: boolean;
excludePastValidUntil?: boolean;
}
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
export interface ShlinkShortUrlsListNormalizedParams extends
Omit<ShlinkShortUrlsListParams, 'orderBy' | 'excludeMaxVisitsReached' | 'excludePastValidUntil'> {
orderBy?: string;
excludeMaxVisitsReached?: 'true';
excludePastValidUntil?: 'true';
}

View file

@ -12,7 +12,6 @@ import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
import classNames from 'classnames';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features';
import './AsideMenu.scss';
export interface AsideMenuProps {
@ -22,6 +21,7 @@ export interface AsideMenuProps {
interface AsideMenuItemProps extends NavLinkProps {
to: string;
className?: string;
}
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
@ -40,7 +40,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation();
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
@ -68,12 +67,10 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</AsideMenuItem>
{addManageDomainsLink && (
<AsideMenuItem to={buildPath('/manage-domains')}>
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
<span className="aside-menu__item-text">Manage domains</span>
</AsideMenuItem>
)}
<AsideMenuItem to={buildPath('/manage-domains')}>
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
<span className="aside-menu__item-text">Manage domains</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>

View file

@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
import { supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data';
import { NotFound } from './NotFound';
import { AsideMenuProps } from './AsideMenu';
@ -38,7 +38,6 @@ export const MenuLayout = (
useEffect(() => hideSidebar(), [location]);
useEffect(() => {
showContent && sidebarPresent();
return () => sidebarNotPresent();
}, []);
@ -47,7 +46,6 @@ export const MenuLayout = (
}
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
@ -73,7 +71,7 @@ export const MenuLayout = (
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} />
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
<Route path="/manage-domains" element={<ManageDomains />} />
<Route
path="*"
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}

View file

@ -15,9 +15,9 @@ import { HttpClient } from './HttpClient';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.constant('fetch', (global as any).fetch.bind(global));
bottle.constant('window', window);
bottle.constant('console', console);
bottle.constant('fetch', window.fetch.bind(window));
bottle.service('HttpClient', HttpClient, 'fetch');
bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window');

View file

@ -1,7 +1,7 @@
import { FC } from 'react';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { useGoBack } from '../utils/helpers/hooks';
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data';
@ -10,8 +10,11 @@ interface EditServerProps {
editServer: (serverId: string, serverData: ServerData) => void;
}
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
{ editServer, selectedServer, selectServer },
) => {
const goBack = useGoBack();
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
if (!isServerWithId(selectedServer)) {
return null;
@ -19,6 +22,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData);
reconnect === 'true' && selectServer(selectedServer.id);
goBack();
};

View file

@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
import { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
@ -25,7 +25,7 @@ interface OverviewConnectProps {
}
export const Overview = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
ShortUrlsTable: ShortUrlsTableType,
CreateShortUrl: FC<CreateShortUrlProps>,
) => boundToMercureHub(({
shortUrlsList,

View file

@ -37,7 +37,7 @@ export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</h5>
</div>
)}

View file

@ -1,7 +1,7 @@
import { createAction, createListenerMiddleware, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { identity, memoizeWith, pipe } from 'ramda';
import { memoizeWith, pipe } from 'ramda';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
import { isReachableServer, SelectedServer } from '../data';
import { isReachableServer, SelectedServer, ServerWithId } from '../data';
import { ShlinkHealth } from '../../api/types';
import { createAsyncThunk } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
@ -18,8 +18,8 @@ const versionToSemVer = pipe(
);
const getServerVersion = memoizeWith(
identity,
async (_serverId: string, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
(server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`,
async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
version: versionToSemVer(version),
printableVersion: versionToPrintable(version),
})),
@ -43,7 +43,7 @@ export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr
try {
const { health } = buildShlinkApiClient(selectedServer);
const { version, printableVersion } = await getServerVersion(serverId, health);
const { version, printableVersion } = await getServerVersion(selectedServer, health);
return {
...selectedServer,

View file

@ -12,12 +12,13 @@ import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
import pack from '../package.json';
declare const self: ServiceWorkerGlobalScope;
clientsClaim();
// Precache all of the assets generated by your build process.
// Precache all the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
@ -49,7 +50,7 @@ registerRoute(
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
createHandlerBoundToURL(`${pack.homepage}/index.html`)
);
// An example runtime caching route for requests that aren't handled by the

View file

@ -9,6 +9,7 @@
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
import pack from'../package.json';
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
@ -26,7 +27,7 @@ type Config = {
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href);
const publicUrl = new URL(pack.homepage, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
@ -35,7 +36,7 @@ export function register(config?: Config) {
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
const swUrl = `${pack.homepage}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.

View file

@ -1,10 +1,7 @@
import { FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
@ -15,14 +12,6 @@ interface TagsProps {
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
<SimpleCard title="Tags" className="h-100">
<LabeledFormGroup label="Default display mode when managing tags:">
<TagsModeDropdown
mode={tags?.defaultMode ?? 'cards'}
renderTitle={(tagsMode) => capitalize(tagsMode)}
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
/>
<FormText>Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</FormText>
</LabeledFormGroup>
<LabeledFormGroup noMargin label="Default ordering for tags list:">
<OrderingDropdown
items={TAGS_ORDERABLE_FIELDS}

View file

@ -1,20 +1,39 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import { FormText } from '../utils/forms/FormText';
import { DateInterval } from '../utils/helpers/dateIntervals';
interface VisitsProps {
settings: Settings;
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
}
const currentDefaultInterval = (settings: Settings): DateInterval => settings.visits?.defaultInterval ?? 'last30Days';
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<SimpleCard title="Visits" className="h-100">
<FormGroup>
<ToggleSwitch
checked={!!settings.visits?.excludeBots}
onChange={(excludeBots) => setVisitsSettings(
{ defaultInterval: currentDefaultInterval(settings), excludeBots },
)}
>
Exclude bots wherever possible (this option&lsquo;s effect might depend on Shlink server&lsquo;s version).
<FormText>
The visits coming from potential bots will be <b>{settings.visits?.excludeBots ? 'excluded' : 'included'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<DateIntervalSelector
allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'}
active={currentDefaultInterval(settings)}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/>
</LabeledFormGroup>

View file

@ -11,12 +11,5 @@ export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
}
// The "tags display mode" option has been moved from "ui" to "tags"
state.settings.tags = {
...state.settings.tags,
defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode,
};
state.settings.ui && delete (state.settings.ui as any).tagsMode;
return state;
};

View file

@ -28,19 +28,17 @@ export interface ShortUrlCreationSettings {
forwardQuery?: boolean;
}
export type TagsMode = 'cards' | 'list';
export interface UiSettings {
theme: Theme;
}
export interface VisitsSettings {
defaultInterval: DateInterval;
excludeBots?: boolean;
}
export interface TagsSettings {
defaultOrdering?: TagsOrder;
defaultMode?: TagsMode;
}
export interface ShortUrlsListSettings {

View file

@ -21,7 +21,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
if (pagesCount <= 1) {
return null;
return <div className="pb-3" />; // Return some space
}
const renderPages = () =>
@ -38,7 +38,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
));
return (
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
<Pagination className="sticky-card-paginator py-3" listClassName="flex-wrap justify-content-center mb-0">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
</PaginationItem>

View file

@ -4,7 +4,7 @@ import { Button, FormGroup, Input, Row } from 'reactstrap';
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
import { parseISO } from 'date-fns';
import { DateTimeInput, DateTimeInputProps } from '../utils/dates/DateTimeInput';
import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features';
import { supportsForwardQuery } from '../utils/helpers/features';
import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
import { Checkbox } from '../utils/Checkbox';
@ -113,16 +113,14 @@ export const ShortUrlForm = (
</>
);
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
const showForwardQueryControl = supportsForwardQuery(selectedServer);
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
return (
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
{isBasicMode && basicComponents}
{!isBasicMode && (
<>
<SimpleCard title="Basic options" className="mb-3">
<SimpleCard title="Main options" className="mb-3">
{basicComponents}
</SimpleCard>
@ -190,30 +188,26 @@ export const ShortUrlForm = (
)}
</SimpleCard>
</div>
{showBehaviorCard && (
<div className="col-sm-6 mb-3">
<SimpleCard title="Configure behavior">
{showCrawlableControl && (
<ShortUrlFormCheckboxGroup
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
checked={shortUrlData.crawlable}
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
>
Make it crawlable
</ShortUrlFormCheckboxGroup>
)}
{showForwardQueryControl && (
<ShortUrlFormCheckboxGroup
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
checked={shortUrlData.forwardQuery}
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
>
Forward query params on redirect
</ShortUrlFormCheckboxGroup>
)}
</SimpleCard>
</div>
)}
<div className="col-sm-6 mb-3">
<SimpleCard title="Configure behavior">
<ShortUrlFormCheckboxGroup
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
checked={shortUrlData.crawlable}
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
>
Make it crawlable
</ShortUrlFormCheckboxGroup>
{showForwardQueryControl && (
<ShortUrlFormCheckboxGroup
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
checked={shortUrlData.forwardQuery}
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
>
Forward query params on redirect
</ShortUrlFormCheckboxGroup>
)}
</SimpleCard>
</div>
</Row>
</>
)}

View file

@ -8,7 +8,7 @@ import { SearchField } from '../utils/SearchField';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date';
import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals';
import { supportsAllTagsFiltering } from '../utils/helpers/features';
import { supportsAllTagsFiltering, supportsFilterDisabledUrls } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown';
@ -16,11 +16,14 @@ import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
import { Settings } from '../settings/reducers/settings';
import './ShortUrlsFilteringBar.scss';
export interface ShortUrlsFilteringProps {
interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder;
settings: Settings;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string;
shortUrlsAmount?: number;
@ -29,8 +32,20 @@ export interface ShortUrlsFilteringProps {
export const ShortUrlsFilteringBar = (
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery();
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
const [filter, toFirstPage] = useShortUrlsQuery();
const {
search,
tags,
startDate,
endDate,
excludeBots,
excludeMaxVisitsReached,
excludePastValidUntil,
tagsMode = 'any',
} = filter;
const supportsDisabledFiltering = supportsFilterDisabledUrls(selectedServer);
const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
startDate: formatIsoDate(theStartDate) ?? undefined,
@ -69,11 +84,25 @@ export const ShortUrlsFilteringBar = (
<Row className="flex-lg-row-reverse">
<div className="col-lg-8 col-xl-6 mt-3">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={datesToDateRange(startDate, endDate)}
onDatesChange={setDates}
/>
<div className="d-md-flex">
<div className="flex-fill">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={datesToDateRange(startDate, endDate)}
onDatesChange={setDates}
/>
</div>
<ShortUrlsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
selected={{
excludeBots: excludeBots ?? settings.visits?.excludeBots,
excludeMaxVisitsReached,
excludePastValidUntil,
}}
onChange={toFirstPage}
supportsDisabledFiltering={supportsDisabledFiltering}
/>
</div>
</div>
<div className="col-6 col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} />
@ -90,3 +119,5 @@ export const ShortUrlsFilteringBar = (
</div>
);
};
export type ShortUrlsFilteringBarType = ReturnType<typeof ShortUrlsFilteringBar>;

View file

@ -1,5 +1,5 @@
import { pipe } from 'ramda';
import { FC, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Card } from 'reactstrap';
import { useLocation, useParams } from 'react-router-dom';
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
@ -7,14 +7,15 @@ import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { ShlinkShortUrlsListParams } from '../api/types';
import { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types';
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableProps } from './ShortUrlsTable';
import { ShortUrlsTableType } from './ShortUrlsTable';
import { Paginator } from './Paginator';
import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
import { supportsExcludeBotsOnShortUrls } from '../utils/helpers/features';
interface ShortUrlsListProps {
selectedServer: SelectedServer;
@ -24,18 +25,30 @@ interface ShortUrlsListProps {
}
export const ShortUrlsList = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
ShortUrlsTable: ShortUrlsTableType,
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
const { page } = useParams();
const location = useLocation();
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage] = useShortUrlsQuery();
const [filter, toFirstPage] = useShortUrlsQuery();
const {
tags,
search,
startDate,
endDate,
orderBy,
tagsMode,
excludeBots,
excludePastValidUntil,
excludeMaxVisitsReached,
} = filter;
const [actualOrderBy, setActualOrderBy] = useState(
// 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,
);
const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir });
@ -48,6 +61,13 @@ export const ShortUrlsList = (
(newTag: string) => [...new Set([...tags, newTag])],
(updatedTags) => toFirstPage({ tags: updatedTags }),
);
const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => {
if (supportsExcludeBotsOnShortUrls(selectedServer) && doExcludeBots && field === 'visits') {
return { field: 'nonBotVisits', dir };
}
return { field, dir };
};
useEffect(() => {
listShortUrls({
@ -56,10 +76,23 @@ export const ShortUrlsList = (
tags,
startDate,
endDate,
orderBy: actualOrderBy,
orderBy: parseOrderByForShlink(actualOrderBy),
tagsMode,
excludePastValidUntil,
excludeMaxVisitsReached,
});
}, [page, search, tags, startDate, endDate, actualOrderBy, tagsMode]);
}, [
page,
search,
tags,
startDate,
endDate,
actualOrderBy.field,
actualOrderBy.dir,
tagsMode,
excludePastValidUntil,
excludeMaxVisitsReached,
]);
return (
<>
@ -68,9 +101,10 @@ export const ShortUrlsList = (
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
settings={settings}
className="mb-3"
/>
<Card body className="pb-1">
<Card body className="pb-0">
<ShortUrlsTable
selectedServer={selectedServer}
shortUrlsList={shortUrlsList}

View file

@ -1,3 +1,7 @@
.short-urls-table.short-urls-table {
margin-bottom: -1px;
}
.short-urls-table__header-cell--with-action {
cursor: pointer;
}

View file

@ -1,13 +1,13 @@
import { FC, ReactNode } from 'react';
import { ReactNode } from 'react';
import { isEmpty } from 'ramda';
import classNames from 'classnames';
import { SelectedServer } from '../servers/data';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
import { ShortUrlsRowType } from './helpers/ShortUrlsRow';
import { ShortUrlsOrderableFields } from './data';
import './ShortUrlsTable.scss';
export interface ShortUrlsTableProps {
interface ShortUrlsTableProps {
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
shortUrlsList: ShortUrlsListState;
@ -16,7 +16,7 @@ export interface ShortUrlsTableProps {
className?: string;
}
export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
orderByColumn,
renderOrderIcon,
shortUrlsList,
@ -27,7 +27,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
const { error, loading, shortUrls } = shortUrlsList;
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
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 short-urls-table', className);
const renderShortUrls = () => {
if (error) {
@ -81,7 +81,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
</th>
<th className="short-urls-table__header-cell">&nbsp;</th>
<th className="short-urls-table__header-cell" colSpan={2} />
</tr>
</thead>
<tbody>
@ -90,3 +90,5 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
</table>
);
};
export type ShortUrlsTableType = ReturnType<typeof ShortUrlsTable>;

View file

@ -31,7 +31,9 @@ export interface ShortUrl {
shortUrl: string;
longUrl: string;
dateCreated: string;
visitsCount: number;
/** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0
visitsSummary?: ShortUrlVisitsSummary; // Optional only before Shlink 3.4.0
meta: Required<Nullable<ShortUrlMeta>>;
tags: string[];
domain: string | null;
@ -46,6 +48,12 @@ export interface ShortUrlMeta {
maxVisits?: number;
}
export interface ShortUrlVisitsSummary {
total: number;
nonBots: number;
bots: number;
}
export interface ShortUrlModalProps {
shortUrl: ShortUrl;
isOpen: boolean;
@ -72,3 +80,9 @@ export interface ExportableShortUrl {
tags: string;
visits: number;
}
export interface ShortUrlsFilter {
excludeBots?: boolean;
excludeMaxVisitsReached?: boolean;
excludePastValidUntil?: boolean;
}

View file

@ -52,7 +52,7 @@ export const ExportShortUrlsBtn = (
longUrl: shortUrl.longUrl,
title: shortUrl.title ?? '',
tags: shortUrl.tags.join(','),
visits: shortUrl.visitsCount,
visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount,
})));
stopLoading();
};

View file

@ -6,8 +6,8 @@ import { ExternalLink } from 'react-external-link';
import { ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { supportsNonRestCors, supportsQrErrorCorrection } from '../../utils/helpers/features';
import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { supportsNonRestCors } from '../../utils/helpers/features';
import { ImageDownloader } from '../../common/services/ImageDownloader';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
@ -24,14 +24,10 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const capabilities: QrCodeCapabilities = useMemo(() => ({
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
}), [selectedServer]);
const displayDownloadBtn = supportsNonRestCors(selectedServer);
const willRenderThreeControls = !capabilities.errorCorrectionIsSupported;
const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
[shortUrl, size, format, margin, errorCorrection, capabilities],
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection],
);
const totalSize = useMemo(() => size + margin, [size, margin]);
const modalSize = useMemo(() => {
@ -49,7 +45,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
</ModalHeader>
<ModalBody>
<Row>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<FormGroup className="d-grid col-md-4">
<label>Size: {size}px</label>
<input
type="range"
@ -61,7 +57,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setSize(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<FormGroup className="d-grid col-md-4">
<label htmlFor="marginControl">Margin: {margin}px</label>
<input
id="marginControl"
@ -74,14 +70,12 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setMargin(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
<FormGroup className="d-grid col-md-4">
<QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup>
{capabilities.errorCorrectionIsSupported && (
<FormGroup className="col-md-6">
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
</FormGroup>
)}
<FormGroup className="col-md-6">
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
</FormGroup>
</Row>
<div className="text-center">
<div className="mb-3">

View file

@ -0,0 +1,86 @@
import { FC, ReactNode, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { faLinkSlash, faCalendarXmark, faCheck } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import { isBefore } from 'date-fns';
import { mutableRefToElementRef } from '../../utils/helpers/components';
import { ShortUrl } from '../data';
import { formatHumanFriendly, now, parseISO } from '../../utils/helpers/date';
interface ShortUrlStatusProps {
shortUrl: ShortUrl;
}
interface StatusResult {
icon: IconDefinition;
className: string;
description: ReactNode;
}
const resolveShortUrlStatus = (shortUrl: ShortUrl): StatusResult => {
const { meta, visitsCount, visitsSummary } = shortUrl;
const { maxVisits, validSince, validUntil } = meta;
const totalVisits = visitsSummary?.total ?? visitsCount;
if (maxVisits && totalVisits >= maxVisits) {
return {
icon: faLinkSlash,
className: 'text-danger',
description: (
<>
This short URL cannot be currently visited because it has reached the maximum
amount of <b>{maxVisits}</b> visit{maxVisits > 1 ? 's' : ''}.
</>
),
};
}
if (validUntil && isBefore(parseISO(validUntil), now())) {
return {
icon: faCalendarXmark,
className: 'text-danger',
description: (
<>
This short URL cannot be visited
since <b className="indivisible">{formatHumanFriendly(parseISO(validUntil))}</b>.
</>
),
};
}
if (validSince && isBefore(now(), parseISO(validSince))) {
return {
icon: faCalendarXmark,
className: 'text-warning',
description: (
<>
This short URL will start working
on <b className="indivisible">{formatHumanFriendly(parseISO(validSince))}</b>.
</>
),
};
}
return {
icon: faCheck,
className: 'text-primary',
description: 'This short URL can be visited normally.',
};
};
export const ShortUrlStatus: FC<ShortUrlStatusProps> = ({ shortUrl }) => {
const tooltipRef = useRef<HTMLElement | undefined>();
const { icon, className, description } = resolveShortUrlStatus(shortUrl);
return (
<>
<span style={{ cursor: !description ? undefined : 'help' }} ref={mutableRefToElementRef(tooltipRef)}>
<FontAwesomeIcon icon={icon} className={className} />
</span>
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom">
{description}
</UncontrolledTooltip>
</>
);
};

View file

@ -10,3 +10,7 @@
.short-url-visits-count__amount--big {
transform: scale(1.5);
}
.short-url-visits-count__tooltip-list-item:not(:last-child) {
margin-bottom: .5rem;
}

View file

@ -7,8 +7,9 @@ import { prettify } from '../../utils/helpers/numbers';
import { ShortUrl } from '../data';
import { SelectedServer } from '../../servers/data';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
import './ShortUrlVisitsCount.scss';
import { mutableRefToElementRef } from '../../utils/helpers/components';
import { formatHumanFriendly, parseISO } from '../../utils/helpers/date';
import './ShortUrlVisitsCount.scss';
interface ShortUrlVisitsCountProps {
shortUrl?: ShortUrl | null;
@ -20,7 +21,8 @@ interface ShortUrlVisitsCountProps {
export const ShortUrlVisitsCount = (
{ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps,
) => {
const maxVisits = shortUrl?.meta?.maxVisits;
const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {};
const hasLimit = !!maxVisits || !!validSince || !!validUntil;
const visitsLink = (
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<strong
@ -31,29 +33,43 @@ export const ShortUrlVisitsCount = (
</ShortUrlDetailLink>
);
if (!maxVisits) {
if (!hasLimit) {
return visitsLink;
}
const prettifiedMaxVisits = prettify(maxVisits);
const tooltipRef = useRef<HTMLElement | undefined>();
return (
<>
<span className="indivisible">
{visitsLink}
<small
className="short-urls-visits-count__max-visits-control"
ref={mutableRefToElementRef(tooltipRef)}
>
{' '}/ {prettifiedMaxVisits}{' '}
<sup>
<small className="short-urls-visits-count__max-visits-control" ref={mutableRefToElementRef(tooltipRef)}>
{maxVisits && <> / {prettify(maxVisits)}</>}
<sup className="ms-1">
<FontAwesomeIcon icon={infoIcon} />
</sup>
</small>
</span>
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom">
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
<ul className="list-unstyled mb-0">
{maxVisits && (
<li className="short-url-visits-count__tooltip-list-item">
This short URL will not accept more than <b>{prettify(maxVisits)}</b> visit{maxVisits === 1 ? '' : 's'}.
</li>
)}
{validSince && (
<li className="short-url-visits-count__tooltip-list-item">
This short URL will not accept visits
before <b className="indivisible">{formatHumanFriendly(parseISO(validSince))}</b>.
</li>
)}
{validUntil && (
<li className="short-url-visits-count__tooltip-list-item">
This short URL will not accept visits
after <b className="indivisible">{formatHumanFriendly(parseISO(validUntil))}</b>.
</li>
)}
</ul>
</UncontrolledTooltip>
</>
);

View file

@ -0,0 +1,46 @@
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../utils/DropdownBtn';
import { hasValue } from '../../utils/utils';
import { ShortUrlsFilter } from '../data';
interface ShortUrlsFilterDropdownProps {
onChange: (filters: ShortUrlsFilter) => void;
supportsDisabledFiltering: boolean;
selected?: ShortUrlsFilter;
className?: string;
}
export const ShortUrlsFilterDropdown = (
{ onChange, selected = {}, className, supportsDisabledFiltering }: ShortUrlsFilterDropdownProps,
) => {
const { excludeBots = false, excludeMaxVisitsReached = false, excludePastValidUntil = false } = selected;
const onFilterClick = (key: keyof ShortUrlsFilter) => () => onChange({ ...selected, [key]: !selected?.[key] });
return (
<DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}>
<DropdownItem header>Visits:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onFilterClick('excludeBots')}>Ignore visits from bots</DropdownItem>
{supportsDisabledFiltering && (
<>
<DropdownItem divider />
<DropdownItem header>Short URLs:</DropdownItem>
<DropdownItem active={excludeMaxVisitsReached} onClick={onFilterClick('excludeMaxVisitsReached')}>
Exclude with visits reached
</DropdownItem>
<DropdownItem active={excludePastValidUntil} onClick={onFilterClick('excludePastValidUntil')}>
Exclude enabled in the past
</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem
disabled={!hasValue(selected)}
onClick={() => onChange({ excludeBots: false, excludeMaxVisitsReached: false, excludePastValidUntil: false })}
>
<i>Clear filters</i>
</DropdownItem>
</DropdownBtn>
);
};

View file

@ -10,10 +10,6 @@
word-break: break-all;
}
.short-urls-row__cell--relative {
position: relative;
}
.short-urls-row__cell--indivisible {
@media (min-width: $lgMin) {
white-space: nowrap;

View file

@ -1,51 +1,47 @@
import { FC, useEffect, useRef } from 'react';
import { isEmpty } from 'ramda';
import { ExternalLink } from 'react-external-link';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
import { TimeoutToggle } from '../../utils/helpers/hooks';
import { Tag } from '../../tags/helpers/Tag';
import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { ShortUrl } from '../data';
import { Time } from '../../utils/dates/Time';
import { Settings } from '../../settings/reducers/settings';
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu';
import { Tags } from './Tags';
import { ShortUrlStatus } from './ShortUrlStatus';
import { useShortUrlsQuery } from './hooks';
import './ShortUrlsRow.scss';
export interface ShortUrlsRowProps {
interface ShortUrlsRowProps {
onTagClick?: (tag: string) => void;
selectedServer: SelectedServer;
shortUrl: ShortUrl;
}
interface ShortUrlsRowConnectProps extends ShortUrlsRowProps {
settings: Settings;
}
export type ShortUrlsRowType = FC<ShortUrlsRowProps>;
export const ShortUrlsRow = (
ShortUrlsRowMenu: FC<ShortUrlsRowMenuProps>,
ShortUrlsRowMenu: ShortUrlsRowMenuType,
colorGenerator: ColorGenerator,
useTimeoutToggle: TimeoutToggle,
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
) => ({ shortUrl, selectedServer, onTagClick, settings }: ShortUrlsRowConnectProps) => {
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
const [active, setActive] = useTimeoutToggle(false, 500);
const isFirstRun = useRef(true);
const renderTags = (tags: string[]) => {
if (isEmpty(tags)) {
return <i className="indivisible"><small>No tags</small></i>;
}
return tags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => onTagClick?.(tag)}
/>
));
};
const [{ excludeBots }] = useShortUrlsQuery();
const { visits } = settings;
const doExcludeBots = excludeBots ?? visits?.excludeBots;
useEffect(() => {
!isFirstRun.current && setActive();
isFirstRun.current = false;
}, [shortUrl.visitsCount]);
}, [shortUrl.visitsSummary?.total, shortUrl.visitsSummary?.nonBots, shortUrl.visitsCount]);
return (
<tr className="responsive-table__row">
@ -53,7 +49,7 @@ export const ShortUrlsRow = (
<Time date={shortUrl.dateCreated} />
</td>
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
<span className="short-urls-row__cell--relative short-urls-row__cell--indivisible">
<span className="position-relative short-urls-row__cell--indivisible">
<span className="short-urls-row__short-url-wrapper">
<ExternalLink href={shortUrl.shortUrl} />
</span>
@ -74,15 +70,22 @@ export const ShortUrlsRow = (
<ExternalLink href={shortUrl.longUrl} />
</td>
)}
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">
<Tags tags={shortUrl.tags} colorGenerator={colorGenerator} onTagClick={onTagClick} />
</td>
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
<ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
visitsCount={(
doExcludeBots ? shortUrl.visitsSummary?.nonBots : shortUrl.visitsSummary?.total
) ?? shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
active={active}
/>
</td>
<td className="responsive-table__cell short-urls-row__cell" data-th="Status">
<ShortUrlStatus shortUrl={shortUrl} />
</td>
<td className="responsive-table__cell short-urls-row__cell">
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
</td>

View file

@ -13,7 +13,7 @@ import { SelectedServer } from '../../servers/data';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
export interface ShortUrlsRowMenuProps {
interface ShortUrlsRowMenuProps {
selectedServer: SelectedServer;
shortUrl: ShortUrl;
}
@ -51,3 +51,5 @@ export const ShortUrlsRowMenu = (
</DropdownBtnMenu>
);
};
export type ShortUrlsRowMenuType = ReturnType<typeof ShortUrlsRowMenu>;

View file

@ -0,0 +1,29 @@
import { FC } from 'react';
import { isEmpty } from 'ramda';
import { Tag } from '../../tags/helpers/Tag';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
interface TagsProps {
tags: string[];
onTagClick?: (tag: string) => void;
colorGenerator: ColorGenerator;
}
export const Tags: FC<TagsProps> = ({ tags, onTagClick, colorGenerator }) => {
if (isEmpty(tags)) {
return <i className="indivisible"><small>No tags</small></i>;
}
return (
<>
{tags.map((tag) => (
<Tag
key={tag}
text={tag}
colorGenerator={colorGenerator}
onClick={() => onTagClick?.(tag)}
/>
))}
</>
);
};

View file

@ -5,6 +5,7 @@ import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
import { TagsFilteringMode } from '../../api/types';
import { BooleanString, parseOptionalBooleanToString } from '../../utils/utils';
interface ShortUrlsQueryCommon {
search?: string;
@ -16,11 +17,17 @@ interface ShortUrlsQueryCommon {
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string;
tags?: string;
excludeBots?: BooleanString;
excludeMaxVisitsReached?: BooleanString;
excludePastValidUntil?: BooleanString;
}
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder;
tags: string[];
excludeBots?: boolean;
excludeMaxVisitsReached?: boolean;
excludePastValidUntil?: boolean;
}
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
@ -33,20 +40,31 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const filtering = useMemo(
pipe(
() => parseQuery<ShortUrlsQuery>(search),
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
({ orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...rest }): ShortUrlsFiltering => {
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
const parsedTags = tags?.split(',') ?? [];
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
return {
...rest,
orderBy: parsedOrderBy,
tags: parsedTags,
excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined,
excludeMaxVisitsReached: excludeMaxVisitsReached !== undefined ? excludeMaxVisitsReached === 'true' : undefined,
excludePastValidUntil: excludePastValidUntil !== undefined ? excludePastValidUntil === 'true' : undefined,
};
},
),
[search],
);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, tags, ...mergedFiltering } = { ...filtering, ...extra };
const merged = { ...filtering, ...extra };
const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged;
const query: ShortUrlsQuery = {
...mergedFiltering,
orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined,
excludeBots: parseOptionalBooleanToString(excludeBots),
excludeMaxVisitsReached: parseOptionalBooleanToString(excludeMaxVisitsReached),
excludePastValidUntil: parseOptionalBooleanToString(excludePastValidUntil),
};
const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;

View file

@ -1,5 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { assoc, assocPath, last, pipe, reject } from 'ramda';
import { assocPath, last, pipe, reject } from 'ramda';
import { shortUrlMatches } from '../helpers';
import { createNewVisits } from '../../visits/reducers/visitCreation';
import { createAsyncThunk } from '../../utils/helpers/redux';
@ -101,18 +101,12 @@ export const shortUrlsListReducerCreator = (
(state, { payload }) => assocPath(
['shortUrls', 'data'],
state.shortUrls?.data?.map(
(currentShortUrl) => {
// Find the last of the new visit for this short URL, and pick the amount of visits from it
const lastVisit = last(
payload.createdVisits.filter(
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
),
);
return lastVisit?.shortUrl
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
: currentShortUrl;
},
// Find the last of the new visit for this short URL, and pick its short URL. It will have an up-to-date amount of visits.
(currentShortUrl) => last(
payload.createdVisits.filter(
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
),
)?.shortUrl ?? currentShortUrl,
),
state,
),

View file

@ -28,7 +28,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
bottle.decorator('ShortUrlsRow', connect(['settings']));
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector');

View file

@ -1,38 +0,0 @@
@import '../utils/base';
.tag-card.tag-card {
margin-bottom: .5rem;
}
.tag-card__header.tag-card__header,
.tag-card__body.tag-card__body {
padding: .75rem;
}
.tag-card__tag-title {
margin: 0;
line-height: 31px;
padding-right: 5px;
}
.tag-card__btn {
float: right;
}
.tag-card__btn--last {
margin-left: 3px;
}
.tag-card__table-cell.tag-card__table-cell {
border: none;
}
.tag-card__tag-name {
color: $mainColor;
cursor: pointer;
}
.tag-card__tag-name:hover {
color: darken($mainColor, 15%);
text-decoration: underline;
}

View file

@ -1,89 +0,0 @@
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
import { FC, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks';
import { ColorGenerator } from '../utils/services/ColorGenerator';
import { getServerId, SelectedServer } from '../servers/data';
import { TagBullet } from './helpers/TagBullet';
import { NormalizedTag, TagModalProps } from './data';
import './TagCard.scss';
import { mutableRefToElementRef } from '../utils/helpers/components';
export interface TagCardProps {
tag: NormalizedTag;
selectedServer: SelectedServer;
displayed: boolean;
toggle: () => void;
}
const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth;
export const TagCard = (
DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator,
) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
const [isDeleteModalOpen, toggleDelete] = useToggle();
const [isEditModalOpen, toggleEdit] = useToggle();
const [hasTitle,, displayTitle] = useToggle();
const titleRef = useRef<HTMLHeadingElement | undefined>();
const serverId = getServerId(selectedServer);
useEffect(() => {
if (isTruncated(titleRef.current)) {
displayTitle();
}
}, [titleRef.current]);
return (
<Card className="tag-card">
<CardHeader className="tag-card__header">
<Button
aria-label="Delete tag"
color="link"
size="sm"
className="tag-card__btn tag-card__btn--last"
onClick={toggleDelete}
>
<FontAwesomeIcon icon={deleteIcon} />
</Button>
<Button aria-label="Edit tag" color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</Button>
<h5
className="tag-card__tag-title text-ellipsis"
title={hasTitle ? tag.tag : undefined}
ref={mutableRefToElementRef(titleRef)}
>
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
<span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
</h5>
</CardHeader>
<Collapse isOpen={displayed}>
<CardBody className="tag-card__body">
<Link
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="me-2" />Short URLs</span>
<b>{prettify(tag.shortUrls)}</b>
</Link>
<Link
to={`/server/${serverId}/tag/${tag.tag}/visits`}
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="me-2" />Visits</span>
<b>{prettify(tag.visits)}</b>
</Link>
</CardBody>
</Collapse>
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
</Card>
);
};

View file

@ -1,32 +0,0 @@
import { FC, useState } from 'react';
import { splitEvery } from 'ramda';
import { Row } from 'reactstrap';
import { TagCardProps } from './TagCard';
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4;
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ sortedTags, selectedServer }) => {
const [displayedTag, setDisplayedTag] = useState<string | undefined>();
const tagsCount = sortedTags.length;
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags);
return (
<Row>
{tagsGroups.map((group, index) => (
<div key={index} className="col-md-6 col-xl-3">
{group.map((tag) => (
<TagCard
key={tag.tag}
tag={tag}
selectedServer={selectedServer}
displayed={displayedTag === tag.tag}
toggle={() => setDisplayedTag(displayedTag !== tag.tag ? tag.tag : undefined)}
/>
))}
</div>
))}
</Row>
);
};

View file

@ -8,17 +8,11 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { Topics } from '../mercure/helpers/Topics';
import { Settings, TagsMode } from '../settings/reducers/settings';
import { Settings } from '../settings/reducers/settings';
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { TagsList as TagsListState } from './reducers/tagsList';
import {
TagsOrderableFields,
TAGS_ORDERABLE_FIELDS,
TagsListChildrenProps,
TagsOrder,
} from './data/TagsListChildrenProps';
import { TagsModeDropdown } from './TagsModeDropdown';
import { TagsOrderableFields, TAGS_ORDERABLE_FIELDS, TagsOrder } from './data/TagsListChildrenProps';
import { NormalizedTag } from './data';
import { TagsTableProps } from './TagsTable';
@ -30,10 +24,9 @@ export interface TagsListProps {
settings: Settings;
}
export const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => {
const [mode, setMode] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards');
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
const resolveSortedTags = pipe(
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
@ -73,26 +66,21 @@ export const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<Tag
const sortedTags = resolveSortedTags();
return mode === 'cards'
? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
: (
<TagsTable
sortedTags={sortedTags}
selectedServer={selectedServer}
currentOrder={order}
orderByColumn={orderByColumn}
/>
);
return (
<TagsTable
sortedTags={sortedTags}
selectedServer={selectedServer}
currentOrder={order}
orderByColumn={orderByColumn}
/>
);
};
return (
<>
<SearchField className="mb-3" onChange={filterTags} />
<Row className="mb-3">
<div className="col-lg-6">
<TagsModeDropdown mode={mode} onChange={setMode} />
</div>
<div className="col-lg-6 mt-3 mt-lg-0">
<div className="col-lg-6 offset-lg-6">
<OrderingDropdown
items={TAGS_ORDERABLE_FIELDS}
order={order}

View file

@ -1,23 +0,0 @@
import { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
import { DropdownBtn } from '../utils/DropdownBtn';
import { TagsMode } from '../settings/reducers/settings';
interface TagsModeDropdownProps {
mode: TagsMode;
onChange: (newMode: TagsMode) => void;
renderTitle?: (mode: TagsMode) => string;
}
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="me-1" /> Cards
</DropdownItem>
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
<FontAwesomeIcon icon={listIcon} fixedWidth className="me-1" /> List
</DropdownItem>
</DropdownBtn>
);

View file

@ -1,7 +1,6 @@
import { prop } from 'ramda';
import Bottle, { IContainer } from 'bottlejs';
import { TagsSelector } from '../helpers/TagsSelector';
import { TagCard } from '../TagCard';
import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal';
import { EditTagModal } from '../helpers/EditTagModal';
import { TagsList } from '../TagsList';
@ -9,7 +8,6 @@ import { filterTags, listTags, tagsListReducerCreator } from '../reducers/tagsLi
import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete';
import { editTag, tagEdited, tagEditReducerCreator } from '../reducers/tagEdit';
import { ConnectDecorator } from '../../container/types';
import { TagsCards } from '../TagsCards';
import { TagsTable } from '../TagsTable';
import { TagsTableRow } from '../TagsTableRow';
@ -18,20 +16,17 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect(['tagsList', 'settings'], ['listTags']));
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect(['tagDelete'], ['deleteTag', 'tagDeleted']));
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited']));
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
bottle.serviceFactory('TagsList', TagsList, 'TagsTable');
bottle.decorator('TagsList', connect(
['tagsList', 'selectedServer', 'mercureInfo', 'settings'],
['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'],

View file

@ -1,5 +1,5 @@
import csv from 'csvtojson';
import { parse } from 'json2csv';
import { Parser } from '@json2csv/plainjs';
export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) => {
csv().fromString(csvContent).then(resolve);
@ -7,6 +7,8 @@ export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) =
export type CsvToJson = typeof csvToJson;
export const jsonToCsv = <T>(data: T[]): string => parse(data);
const jsonParser = new Parser(); // TODO This accepts options if needed
export const jsonToCsv = <T>(data: T[]): string => jsonParser.parse(data);
export type JsonToCsv = typeof jsonToCsv;

View file

@ -30,6 +30,8 @@ export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date,
export const formatInternational = formatDate();
export const formatHumanFriendly = formatDate(STANDARD_DATE_AND_TIME_FORMAT);
export const parseDate = (date: string, theFormat: string) => parse(date, theFormat, now());
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));

View file

@ -4,13 +4,11 @@ import { SemVerPattern, versionMatch } from './version';
const serverMatchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: SelectedServer): boolean =>
isReachableServer(selectedServer) && versionMatch(selectedServer.version, { minVersion });
export const supportsBotVisits = serverMatchesMinVersion('2.7.0');
export const supportsCrawlableVisits = supportsBotVisits;
export const supportsQrErrorCorrection = serverMatchesMinVersion('2.8.0');
export const supportsDomainRedirects = supportsQrErrorCorrection;
export const supportsForwardQuery = serverMatchesMinVersion('2.9.0');
export const supportsNonRestCors = supportsForwardQuery;
export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0');
export const supportsNonOrphanVisits = serverMatchesMinVersion('3.0.0');
export const supportsAllTagsFiltering = supportsNonOrphanVisits;
export const supportsDomainVisits = serverMatchesMinVersion('3.1.0');
export const supportsExcludeBotsOnShortUrls = serverMatchesMinVersion('3.4.0');
export const supportsFilterDisabledUrls = supportsExcludeBotsOnShortUrls;

View file

@ -1,6 +1,6 @@
import { useState, useRef, EffectCallback, DependencyList, useEffect } from 'react';
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { v4 as uuid } from 'uuid';
import { parseQuery, stringifyQuery } from './query';
@ -82,6 +82,11 @@ export const useGoBack = () => {
return () => navigate(-1);
};
export const useParsedQuery = <T>(): T => {
const { search } = useLocation();
return parseQuery<T>(search);
};
export const useDomId = (): string => {
const { current: id } = useRef(`dom-${uuid()}`);
return id;

View file

@ -1,10 +1,6 @@
import { isEmpty } from 'ramda';
import { stringifyQuery } from './query';
export interface QrCodeCapabilities {
errorCorrectionIsSupported: boolean;
}
export type QrCodeFormat = 'svg' | 'png';
export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H';
@ -16,17 +12,11 @@ export interface QrCodeOptions {
errorCorrection: QrErrorCorrection;
}
export const buildQrCodeUrl = (
shortUrl: string,
{ size, format, margin, errorCorrection }: QrCodeOptions,
{ errorCorrectionIsSupported }: QrCodeCapabilities,
): string => {
export const buildQrCodeUrl = (shortUrl: string, { margin, ...options }: QrCodeOptions): string => {
const baseUrl = `${shortUrl}/qr-code`;
const query = stringifyQuery({
size,
format,
...options,
margin: margin > 0 ? margin : undefined,
errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined,
});
return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`;

View file

@ -5,15 +5,15 @@ import { ColorGenerator } from './ColorGenerator';
import { csvToJson, jsonToCsv } from '../helpers/csvjson';
const provideServices = (bottle: Bottle) => {
bottle.constant('localStorage', (global as any).localStorage);
bottle.constant('localStorage', window.localStorage);
bottle.service('Storage', LocalStorage, 'localStorage');
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
bottle.constant('csvToJson', csvToJson);
bottle.constant('jsonToCsv', jsonToCsv);
bottle.constant('setTimeout', global.setTimeout);
bottle.constant('clearTimeout', global.clearTimeout);
bottle.constant('setTimeout', window.setTimeout);
bottle.constant('clearTimeout', window.clearTimeout);
bottle.serviceFactory('useTimeoutToggle', useTimeoutToggle, 'setTimeout', 'clearTimeout');
};

View file

@ -15,10 +15,13 @@
.responsive-table__row {
@media (max-width: $responsiveTableBreakpoint) {
display: block;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color);
border-top: 2px solid var(--border-color);
position: relative;
&:not(:last-child) {
margin-bottom: 10px;
}
}
}

View file

@ -26,3 +26,11 @@ export const nonEmptyValueOrNull = <T>(value: T): T | null => (isEmpty(value) ?
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
export const equals = (value: any) => (otherValue: any) => value === otherValue;
export type BooleanString = 'true' | 'false';
export const parseBooleanToString = (value: boolean): BooleanString => (value ? 'true' : 'false');
export const parseOptionalBooleanToString = (value?: boolean): BooleanString | undefined => (
value === undefined ? undefined : parseBooleanToString(value)
);

View file

@ -22,7 +22,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure
domainVisits,
cancelGetDomainVisits,
settings,
selectedServer,
}: DomainVisitsProps) => {
const goBack = useGoBack();
const { domain = '' } = useParams();
@ -38,7 +37,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure
visitsInfo={domainVisits}
settings={settings}
exportCsv={exportCsv}
selectedServer={selectedServer}
>
<VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} />
</VisitsStats>

View file

@ -20,7 +20,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc
nonOrphanVisits,
cancelGetNonOrphanVisits,
settings,
selectedServer,
}: NonOrphanVisitsProps) => {
const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
@ -34,7 +33,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc
visitsInfo={nonOrphanVisits}
settings={settings}
exportCsv={exportCsv}
selectedServer={selectedServer}
>
<VisitsHeader title="Non-orphan visits" goBack={goBack} visits={nonOrphanVisits.visits} />
</VisitsStats>

View file

@ -21,7 +21,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure
orphanVisits,
cancelGetOrphanVisits,
settings,
selectedServer,
}: OrphanVisitsProps) => {
const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
@ -36,7 +35,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure
visitsInfo={orphanVisits}
settings={settings}
exportCsv={exportCsv}
selectedServer={selectedServer}
isOrphanVisits
>
<VisitsHeader title="Orphan visits" goBack={goBack} visits={orphanVisits.visits} />

View file

@ -30,7 +30,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu
getShortUrlDetail,
cancelGetShortUrlVisits,
settings,
selectedServer,
}: ShortUrlVisitsProps) => {
const { shortCode = '' } = useParams<{ shortCode: string }>();
const { search } = useLocation();
@ -57,7 +56,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu
visitsInfo={shortUrlVisits}
settings={settings}
exportCsv={exportCsv}
selectedServer={selectedServer}
>
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats>

View file

@ -23,7 +23,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo
tagVisits,
cancelGetTagVisits,
settings,
selectedServer,
}: TagVisitsProps) => {
const goBack = useGoBack();
const { tag = '' } = useParams();
@ -38,7 +37,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo
visitsInfo={tagVisits}
settings={settings}
exportCsv={exportCsv}
selectedServer={selectedServer}
>
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats>

View file

@ -11,8 +11,6 @@ import { Message } from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { Settings } from '../settings/reducers/settings';
import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers';
import { NavPillItem, NavPills } from '../utils/NavPills';
import { ExportBtn } from '../utils/ExportBtn';
@ -33,7 +31,6 @@ export type VisitsStatsProps = PropsWithChildren<{
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
visitsInfo: VisitsInfo;
settings: Settings;
selectedServer: SelectedServer;
cancelGetVisits: () => void;
exportCsv: (visits: NormalizedVisit[]) => void;
isOrphanVisits?: boolean;
@ -63,7 +60,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
cancelGetVisits,
settings,
exportCsv,
selectedServer,
isOrphanVisits = false,
}) => {
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
@ -82,7 +78,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
);
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
const botsSupported = supportsBotVisits(selectedServer);
const isFirstLoad = useRef(true);
const { search } = useLocation();
@ -92,6 +87,10 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
() => processStatsFromVisits(normalizedVisits),
[normalizedVisits],
);
const resolvedFilter = useMemo(() => ({
...visitsFilter,
excludeBots: visitsFilter.excludeBots ?? settings.visits?.excludeBots,
}), [visitsFilter]);
const mapLocations = values(citiesForMap);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
@ -115,7 +114,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
useEffect(() => cancelGetVisits, []);
useEffect(() => {
const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current));
getVisits({ dateRange: resolvedDateRange, filter: visitsFilter }, isFirstLoad.current);
getVisits({ dateRange: resolvedDateRange, filter: resolvedFilter }, isFirstLoad.current);
isFirstLoad.current = false;
}, [dateRange, visitsFilter]);
useEffect(() => {
@ -267,7 +266,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits}
selectedServer={selectedServer}
/>
</div>
)}
@ -300,8 +298,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
<VisitsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
isOrphanVisits={isOrphanVisits}
botsSupported={botsSupported}
selected={visitsFilter}
selected={resolvedFilter}
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
/>
</div>

View file

@ -8,8 +8,6 @@ import { SimplePaginator } from '../common/SimplePaginator';
import { SearchField } from '../utils/SearchField';
import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
import { prettify } from '../utils/helpers/numbers';
import { supportsBotVisits } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { Time } from '../utils/dates/Time';
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { MediaMatcher } from '../utils/types';
@ -22,7 +20,6 @@ export interface VisitsTableProps {
setSelectedVisits: (visits: NormalizedVisit[]) => void;
matchMedia?: MediaMatcher;
isOrphanVisits?: boolean;
selectedServer: SelectedServer;
}
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
@ -49,7 +46,6 @@ export const VisitsTable = ({
visits,
selectedVisits = [],
setSelectedVisits,
selectedServer,
matchMedia = window.matchMedia,
isOrphanVisits = false,
}: VisitsTableProps) => {
@ -64,8 +60,7 @@ export const VisitsTable = ({
const [page, setPage] = useState(1);
const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE;
const supportsBots = supportsBotVisits(selectedServer);
const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits);
const fullSizeColSpan = 8 + Number(isOrphanVisits);
const orderByColumn = (field: OrderableFields) =>
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
@ -99,12 +94,10 @@ export const VisitsTable = ({
>
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th>
{supportsBots && (
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<FontAwesomeIcon icon={botIcon} />
{renderOrderIcon('potentialBot')}
</th>
)}
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<FontAwesomeIcon icon={botIcon} />
{renderOrderIcon('potentialBot')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('date')}>
Date
{renderOrderIcon('date')}
@ -165,18 +158,16 @@ export const VisitsTable = ({
<td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td>
{supportsBots && (
<td className="text-center">
{visit.potentialBot && (
<>
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
Potentially a visit from a bot or crawler
</UncontrolledTooltip>
</>
)}
</td>
)}
<td className="text-center">
{visit.potentialBot && (
<>
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
Potentially a visit from a bot or crawler
</UncontrolledTooltip>
</>
)}
</td>
<td><Time date={visit.date} /></td>
<td>{visit.country}</td>
<td>{visit.city}</td>

View file

@ -30,8 +30,8 @@ import { ToggleSwitch } from '../../utils/ToggleSwitch';
import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
import './LineChartCard.scss';
import { STANDARD_DATE_FORMAT } from '../../utils/helpers/date';
import './LineChartCard.scss';
interface LineChartCardProps {
title: string;

View file

@ -8,16 +8,11 @@ interface VisitsFilterDropdownProps {
selected?: VisitsFilter;
className?: string;
isOrphanVisits: boolean;
botsSupported: boolean;
}
export const VisitsFilterDropdown = (
{ onChange, selected = {}, className, isOrphanVisits, botsSupported }: VisitsFilterDropdownProps,
{ onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps,
) => {
if (!botsSupported && !isOrphanVisits) {
return null;
}
const { orphanVisitsType, excludeBots = false } = selected;
const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({
active: orphanVisitsType === type,
@ -27,17 +22,12 @@ export const VisitsFilterDropdown = (
return (
<DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}>
{botsSupported && (
<>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
</>
)}
{botsSupported && isOrphanVisits && <DropdownItem divider />}
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
{isOrphanVisits && (
<>
<DropdownItem divider />
<DropdownItem header>Orphan visits type:</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>

View file

@ -1,17 +1,18 @@
import { DeepPartial } from '@reduxjs/toolkit';
import { useLocation, useNavigate } from 'react-router-dom';
import { useMemo } from 'react';
import { isEmpty, mergeDeepRight, pipe } from 'ramda';
import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda';
import { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals';
import { OrphanVisitType, VisitsFilter } from '../types';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { formatIsoDate } from '../../utils/helpers/date';
import { BooleanString, parseBooleanToString } from '../../utils/utils';
interface VisitsQuery {
startDate?: string;
endDate?: string;
orphanVisitsType?: OrphanVisitType;
excludeBots?: 'true';
excludeBots?: BooleanString;
domain?: string;
}
@ -38,7 +39,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
domain,
filtering: {
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined,
visitsFilter: { orphanVisitsType, excludeBots: excludeBots === 'true' },
visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined },
},
}),
),
@ -46,11 +47,12 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
);
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
const { excludeBots, orphanVisitsType } = visitsFilter;
const query: VisitsQuery = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: visitsFilter.excludeBots ? 'true' : undefined,
orphanVisitsType: visitsFilter.orphanVisitsType,
excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
orphanVisitsType,
domain: theDomain,
};
const stringifiedQuery = stringifyQuery(query);

View file

@ -29,11 +29,11 @@ interface VisitsAsyncThunkOptions<T extends LoadVisits = LoadVisits, R extends V
export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
{ typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
) => {
const progressChangedAction = createAction<number>(`${typePrefix}/progressChanged`);
const largeAction = createAction<void>(`${typePrefix}/large`);
const fallbackToIntervalAction = createAction<DateInterval>(`${typePrefix}/fallbackToInterval`);
const progressChanged = createAction<number>(`${typePrefix}/progressChanged`);
const large = createAction<void>(`${typePrefix}/large`);
const fallbackToInterval = createAction<DateInterval>(`${typePrefix}/fallbackToInterval`);
const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise<R> => {
const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise<Partial<R>> => {
const [visitsLoader, lastVisitLoader] = createLoaders(params, getState);
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
@ -46,7 +46,7 @@ export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R exte
const data = await loadVisitsInParallel(pagesBlocks[index]);
dispatch(progressChangedAction(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE)));
dispatch(progressChanged(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE)));
if (index < pagesBlocks.length - 1) {
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
@ -68,7 +68,7 @@ export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R exte
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
dispatch(largeAction());
dispatch(large());
}
return data.concat(await loadPagesBlocks(pagesBlocks));
@ -77,13 +77,14 @@ export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R exte
const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]);
if (!visits.length && lastVisit) {
dispatch(fallbackToIntervalAction(dateToMatchingInterval(lastVisit.date)));
dispatch(fallbackToInterval(dateToMatchingInterval(lastVisit.date)));
}
return { ...getExtraFulfilledPayload(params), visits } as any; // TODO Get rid of this casting
return { ...getExtraFulfilledPayload(params), visits };
});
return { asyncThunk, progressChangedAction, largeAction, fallbackToIntervalAction };
// Enhance the async thunk with extra actions
return Object.assign(asyncThunk, { progressChanged, large, fallbackToInterval });
};
export const lastVisitLoaderForLoader = (
@ -107,7 +108,7 @@ interface VisitsReducerOptions<State extends VisitsInfo, AT extends ReturnType<t
export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
{ name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions<State, AT>,
) => {
const { asyncThunk, largeAction, fallbackToIntervalAction, progressChangedAction } = asyncThunkCreator;
const { pending, rejected, fulfilled, large, progressChanged, fallbackToInterval } = asyncThunkCreator;
const { reducer, actions } = createSlice({
name,
initialState,
@ -115,17 +116,17 @@ export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnT
cancelGetVisits: (state) => ({ ...state, cancelLoad: true }),
},
extraReducers: (builder) => {
builder.addCase(asyncThunk.pending, () => ({ ...initialState, loading: true }));
builder.addCase(asyncThunk.rejected, (_, { error }) => (
builder.addCase(pending, () => ({ ...initialState, loading: true }));
builder.addCase(rejected, (_, { error }) => (
{ ...initialState, error: true, errorData: parseApiError(error) }
));
builder.addCase(asyncThunk.fulfilled, (state, { payload }) => (
builder.addCase(fulfilled, (state, { payload }) => (
{ ...state, ...payload, loading: false, loadingLarge: false, error: false }
));
builder.addCase(largeAction, (state) => ({ ...state, loadingLarge: true }));
builder.addCase(progressChangedAction, (state, { payload: progress }) => ({ ...state, progress }));
builder.addCase(fallbackToIntervalAction, (state, { payload: fallbackInterval }) => (
builder.addCase(large, (state) => ({ ...state, loadingLarge: true }));
builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress }));
builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => (
{ ...state, fallbackInterval }
));

View file

@ -82,7 +82,7 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
);
export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
const { userAgent, date, referer, visitLocation, potentialBot = false } = visit;
const { userAgent, date, referer, visitLocation, potentialBot } = visit;
const common = {
date,
potentialBot,

View file

@ -22,31 +22,31 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter');
bottle.decorator('ShortUrlVisits', connect(
['shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer'],
['shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings'],
['getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter');
bottle.decorator('TagVisits', connect(
['tagVisits', 'mercureInfo', 'settings', 'selectedServer'],
['tagVisits', 'mercureInfo', 'settings'],
['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter');
bottle.decorator('DomainVisits', connect(
['domainVisits', 'mercureInfo', 'settings', 'selectedServer'],
['domainVisits', 'mercureInfo', 'settings'],
['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter');
bottle.decorator('OrphanVisits', connect(
['orphanVisits', 'mercureInfo', 'settings', 'selectedServer'],
['orphanVisits', 'mercureInfo', 'settings'],
['getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter');
bottle.decorator('NonOrphanVisits', connect(
['nonOrphanVisits', 'mercureInfo', 'settings', 'selectedServer'],
['nonOrphanVisits', 'mercureInfo', 'settings'],
['getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
));
@ -54,24 +54,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('VisitsParser', () => visitsParser);
// Actions
bottle.serviceFactory('getShortUrlVisitsCreator', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlVisits', prop('asyncThunk'), 'getShortUrlVisitsCreator');
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator');
bottle.serviceFactory('getTagVisitsCreator', getTagVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getTagVisits', prop('asyncThunk'), 'getTagVisitsCreator');
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator');
bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator');
bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator');
bottle.serviceFactory('getOrphanVisitsCreator', getOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getOrphanVisits', prop('asyncThunk'), 'getOrphanVisitsCreator');
bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator');
bottle.serviceFactory('getNonOrphanVisitsCreator', getNonOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getNonOrphanVisits', prop('asyncThunk'), 'getNonOrphanVisitsCreator');
bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator');
bottle.serviceFactory('createNewVisits', () => createNewVisits);
@ -81,19 +76,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview');
bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator');
bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisitsCreator');
bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisits');
bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator');
bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisitsCreator');
bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisits');
bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator');
bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisitsCreator');
bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisits');
bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator');
bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisitsCreator');
bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisits');
bottle.serviceFactory('shortUrlVisitsReducer', prop('reducer'), 'shortUrlVisitsReducerCreator');
bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisitsCreator');
bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisits');
bottle.serviceFactory('tagVisitsReducer', prop('reducer'), 'tagVisitsReducerCreator');
};

View file

@ -1,7 +1,5 @@
import { SelectedServer } from '../../servers/data';
import { Settings } from '../../settings/reducers/settings';
export interface CommonVisitsProps {
selectedServer: SelectedServer;
settings: Settings;
}

View file

@ -19,7 +19,7 @@ export interface RegularVisit {
date: string;
userAgent: string;
visitLocation: VisitLocation | null;
potentialBot?: boolean; // Optional only when using Shlink older than v2.7
potentialBot: boolean;
}
export interface OrphanVisit extends RegularVisit {

View file

@ -46,6 +46,28 @@ describe('ShlinkApiClient', () => {
expect.anything(),
);
});
it.each([
[{}, ''],
[{ excludeMaxVisitsReached: false }, ''],
[{ excludeMaxVisitsReached: true }, '?excludeMaxVisitsReached=true'],
[{ excludePastValidUntil: false }, ''],
[{ excludePastValidUntil: true }, '?excludePastValidUntil=true'],
[
{ excludePastValidUntil: true, excludeMaxVisitsReached: true },
'?excludeMaxVisitsReached=true&excludePastValidUntil=true',
],
])('parses disabled URLs params', async (params, expectedQuery) => {
fetchJson.mockResolvedValue({ data: expectedList });
const { listShortUrls } = buildApiClient();
await listShortUrls(params);
expect(fetchJson).toHaveBeenCalledWith(
expect.stringContaining(`/short-urls${expectedQuery}`),
expect.anything(),
);
});
});
describe('createShortUrl', () => {

View file

@ -3,26 +3,22 @@ import { Mock } from 'ts-mockery';
import { MemoryRouter } from 'react-router-dom';
import { AsideMenu as createAsideMenu } from '../../src/common/AsideMenu';
import { ReachableServer } from '../../src/servers/data';
import { SemVer } from '../../src/utils/helpers/version';
describe('<AsideMenu />', () => {
const AsideMenu = createAsideMenu(() => <>DeleteServerButton</>);
const setUp = (version: SemVer, id: string | false = 'abc123') => render(
const setUp = (id: string | false = 'abc123') => render(
<MemoryRouter>
<AsideMenu selectedServer={Mock.of<ReachableServer>({ id: id || undefined, version })} />
<AsideMenu selectedServer={Mock.of<ReachableServer>({ id: id || undefined, version: '2.8.0' })} />
</MemoryRouter>,
);
it.each([
['2.7.0' as SemVer, 5],
['2.8.0' as SemVer, 6],
])('contains links to different sections', (version, expectedAmountOfLinks) => {
setUp(version);
it('contains links to different sections', () => {
setUp();
const links = screen.getAllByRole('link');
expect.assertions(links.length + 1);
expect(links).toHaveLength(expectedAmountOfLinks);
expect(links).toHaveLength(6);
links.forEach((link) => expect(link.getAttribute('href')).toContain('abc123'));
});
@ -30,7 +26,7 @@ describe('<AsideMenu />', () => {
['abc', true],
[false, false],
])('contains a button to delete server if appropriate', (id, shouldHaveBtn) => {
setUp('2.8.0', id as string | false);
setUp(id as string | false);
if (shouldHaveBtn) {
expect(screen.getByText('DeleteServerButton')).toBeInTheDocument();

View file

@ -77,7 +77,6 @@ describe('<MenuLayout />', () => {
['3.1.0' as SemVer, '/domain/domain.com/visits/foo', 'DomainVisits'],
['2.10.0' as SemVer, '/non-orphan-visits/foo', 'Oops! We could not find requested route.'],
['3.0.0' as SemVer, '/non-orphan-visits/foo', 'NonOrphanVisits'],
['2.7.0' as SemVer, '/manage-domains', 'Oops! We could not find requested route.'],
['2.8.0' as SemVer, '/manage-domains', 'ManageDomains'],
])(
'renders expected component based on location and server version',

View file

@ -1,6 +1,6 @@
import { fireEvent, screen } from '@testing-library/react';
import { Mock } from 'ts-mockery';
import { useNavigate } from 'react-router-dom';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { EditServer as editServerConstruct } from '../../src/servers/EditServer';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest';
@ -19,7 +19,9 @@ describe('<EditServer />', () => {
});
const EditServer = editServerConstruct(ServerError);
const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => renderWithEvents(
<EditServer editServer={editServerMock} selectedServer={selectedServer} selectServer={jest.fn()} />,
<MemoryRouter>
<EditServer editServer={editServerMock} selectedServer={selectedServer} selectServer={jest.fn()} />
</MemoryRouter>,
);
beforeEach(() => {

View file

@ -39,11 +39,12 @@ describe('selectedServerReducer', () => {
});
describe('selectServer', () => {
const selectedServer = {
id: 'abc123',
};
const version = '1.19.0';
const createGetStateMock = (id: string) => jest.fn().mockReturnValue({ servers: { [id]: selectedServer } });
const createGetStateMock = (id: string) => jest.fn().mockReturnValue({
servers: {
[id]: { id },
},
});
it.each([
[version, version, `v${version}`],
@ -53,7 +54,7 @@ describe('selectedServerReducer', () => {
const id = uuid();
const getState = createGetStateMock(id);
const expectedSelectedServer = {
...selectedServer,
id,
version: expectedVersion,
printableVersion: expectedPrintableVersion,
};
@ -84,7 +85,7 @@ describe('selectedServerReducer', () => {
it('dispatches error when health endpoint fails', async () => {
const id = uuid();
const getState = createGetStateMock(id);
const expectedSelectedServer = Mock.of<NonReachableServer>({ ...selectedServer, serverNotReachable: true });
const expectedSelectedServer = Mock.of<NonReachableServer>({ id, serverNotReachable: true });
health.mockRejectedValue({});

View file

@ -1,9 +1,8 @@
import { screen } from '@testing-library/react';
import { Mock } from 'ts-mockery';
import { Settings, TagsMode, TagsSettings as TagsSettingsOptions } from '../../src/settings/reducers/settings';
import { Settings, TagsSettings as TagsSettingsOptions } from '../../src/settings/reducers/settings';
import { TagsSettings } from '../../src/settings/TagsSettings';
import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps';
import { capitalize } from '../../src/utils/utils';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<TagsSettings />', () => {
@ -17,35 +16,10 @@ describe('<TagsSettings />', () => {
it('renders expected amount of groups', () => {
setUp();
expect(screen.getByText('Default display mode when managing tags:')).toBeInTheDocument();
expect(screen.getByText('Default ordering for tags list:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Order by...' })).toBeInTheDocument();
});
it.each([
[undefined, 'cards'],
[{}, 'cards'],
[{ defaultMode: 'cards' as TagsMode }, 'cards'],
[{ defaultMode: 'list' as TagsMode }, 'list'],
])('shows expected tags displaying mode', (tags, expectedMode) => {
const { container } = setUp(tags);
expect(screen.getByRole('button', { name: capitalize(expectedMode) })).toBeInTheDocument();
expect(container.querySelector('.form-text')).toHaveTextContent(`Tags will be displayed as ${expectedMode}.`);
});
it.each([
['cards' as TagsMode],
['list' as TagsMode],
])('invokes setTagsSettings when tags mode changes', async (defaultMode) => {
const { user } = setUp();
expect(setTagsSettings).not.toHaveBeenCalled();
await user.click(screen.getByText('List'));
await user.click(screen.getByRole('menuitem', { name: capitalize(defaultMode) }));
expect(setTagsSettings).toHaveBeenCalledWith({ defaultMode });
});
it.each([
[undefined, 'Order by...'],
[{}, 'Order by...'],

View file

@ -17,6 +17,7 @@ describe('<VisitsSettings />', () => {
expect(screen.getByRole('heading')).toHaveTextContent('Visits');
expect(screen.getByText('Default interval to load on visits sections:')).toBeInTheDocument();
expect(screen.getByText(/^Exclude bots wherever possible/)).toBeInTheDocument();
});
it.each([
@ -59,4 +60,36 @@ describe('<VisitsSettings />', () => {
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' });
expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' });
});
it.each([
[
Mock.all<Settings>(),
/The visits coming from potential bots will be included.$/,
/The visits coming from potential bots will be excluded.$/,
],
[
Mock.of<Settings>({ visits: { excludeBots: false } }),
/The visits coming from potential bots will be included.$/,
/The visits coming from potential bots will be excluded.$/,
],
[
Mock.of<Settings>({ visits: { excludeBots: true } }),
/The visits coming from potential bots will be excluded.$/,
/The visits coming from potential bots will be included.$/,
],
])('displays expected helper text for exclude bots control', (settings, expectedText, notExpectedText) => {
setUp(settings);
const visitsComponent = screen.getByText(/^Exclude bots wherever possible/);
expect(visitsComponent).toHaveTextContent(expectedText);
expect(visitsComponent).not.toHaveTextContent(notExpectedText);
});
it('invokes setVisitsSettings when bot exclusion is toggled', async () => {
const { user } = setUp();
await user.click(screen.getByText(/^Exclude bots wherever possible/));
expect(setVisitsSettings).toHaveBeenCalledWith(expect.objectContaining({ excludeBots: true }));
});
});

View file

@ -14,9 +14,6 @@ describe('settings-helpers', () => {
visits: {
defaultInterval: 'last180days' as any,
},
ui: {
tagsMode: 'list',
} as any,
},
});
@ -25,9 +22,6 @@ describe('settings-helpers', () => {
visits: {
defaultInterval: 'last180Days',
},
tags: {
defaultMode: 'list',
},
}),
}));
});

View file

@ -18,9 +18,11 @@ describe('<Paginator />', () => {
[buildPaginator()],
[buildPaginator(0)],
[buildPaginator(1)],
])('renders nothing if the number of pages is below 2', (paginator) => {
])('renders an empty gap if the number of pages is below 2', (paginator) => {
const { container } = setUp(paginator);
expect(container.firstChild).toBeNull();
expect(container.firstChild).toBeEmptyDOMElement();
expect(container.firstChild).toHaveClass('pb-3');
});
it.each([

View file

@ -64,7 +64,7 @@ describe('<ShortUrlForm />', () => {
});
it.each([
['create' as Mode, 4],
['create' as Mode, 5],
['create-basic' as Mode, 0],
])(
'renders expected amount of cards based on server capabilities and mode',

View file

@ -4,6 +4,7 @@ import { endOfDay, formatISO, startOfDay } from 'date-fns';
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings';
import { DateRange } from '../../src/utils/helpers/dateIntervals';
import { formatDate } from '../../src/utils/helpers/date';
import { renderWithEvents } from '../__helpers__/setUpTest';
@ -30,6 +31,7 @@ describe('<ShortUrlsFilteringBar />', () => {
selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
order={{}}
handleOrderBy={handleOrderBy}
settings={Mock.of<Settings>({ visits: {} })}
/>
</MemoryRouter>,
);
@ -114,6 +116,28 @@ describe('<ShortUrlsFilteringBar />', () => {
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
});
it.each([
['', /Ignore visits from bots/, 'excludeBots=true'],
['excludeBots=false', /Ignore visits from bots/, 'excludeBots=true'],
['excludeBots=true', /Ignore visits from bots/, 'excludeBots=false'],
['', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'],
['excludeMaxVisitsReached=false', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'],
['excludeMaxVisitsReached=true', /Exclude with visits reached/, 'excludeMaxVisitsReached=false'],
['', /Exclude enabled in the past/, 'excludePastValidUntil=true'],
['excludePastValidUntil=false', /Exclude enabled in the past/, 'excludePastValidUntil=true'],
['excludePastValidUntil=true', /Exclude enabled in the past/, 'excludePastValidUntil=false'],
])('allows to toggle filters through filtering dropdown', async (search, menuItemName, expectedQuery) => {
const { user } = setUp(search, Mock.of<ReachableServer>({ version: '3.4.0' }));
const toggleFilter = async (name: RegExp) => {
await user.click(screen.getByRole('button', { name: 'Filters' }));
await waitFor(() => screen.findByRole('menu'));
await user.click(screen.getByRole('menuitem', { name }));
};
await toggleFilter(menuItemName);
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedQuery));
});
it('handles order through dropdown', async () => {
const { user } = setUp();
const clickMenuItem = async (name: string | RegExp) => {

View file

@ -1,5 +1,4 @@
import { screen } from '@testing-library/react';
import { FC } from 'react';
import { Mock } from 'ts-mockery';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList';
@ -8,8 +7,9 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
import { ReachableServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings';
import { ShortUrlsTableProps } from '../../src/short-urls/ShortUrlsTable';
import { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable';
import { renderWithEvents } from '../__helpers__/setUpTest';
import { SemVer } from '../../src/utils/helpers/version';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -18,7 +18,7 @@ jest.mock('react-router-dom', () => ({
}));
describe('<ShortUrlsList />', () => {
const ShortUrlsTable: FC<ShortUrlsTableProps> = ({ onTagClick }) => <span onClick={() => onTagClick?.('foo')}>ShortUrlsTable</span>;
const ShortUrlsTable: ShortUrlsTableType = ({ onTagClick }) => <span onClick={() => onTagClick?.('foo')}>ShortUrlsTable</span>;
const ShortUrlsFilteringBar = () => <span>ShortUrlsFilteringBar</span>;
const listShortUrlsMock = jest.fn();
const navigate = jest.fn();
@ -36,14 +36,14 @@ describe('<ShortUrlsList />', () => {
},
});
const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar);
const setUp = (defaultOrdering: ShortUrlsOrder = {}) => renderWithEvents(
const setUp = (settings: Partial<Settings> = {}, version: SemVer = '3.0.0') => renderWithEvents(
<MemoryRouter>
<ShortUrlsList
{...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })}
listShortUrls={listShortUrlsMock}
shortUrlsList={shortUrlsList}
selectedServer={Mock.of<ReachableServer>({ id: '1' })}
settings={Mock.of<Settings>({ shortUrlsList: { defaultOrdering } })}
selectedServer={Mock.of<ReachableServer>({ id: '1', version })}
settings={Mock.of<Settings>(settings)}
/>
</MemoryRouter>,
);
@ -83,11 +83,39 @@ describe('<ShortUrlsList />', () => {
it.each([
[Mock.of<ShortUrlsOrder>({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'],
[Mock.of<ShortUrlsOrder>({ field: 'title', dir: 'DESC' }), 'title', 'DESC'],
[Mock.of<ShortUrlsOrder>(), undefined, undefined],
])('has expected initial ordering based on settings', (initialOrderBy, field, dir) => {
setUp(initialOrderBy);
[Mock.all<ShortUrlsOrder>(), undefined, undefined],
])('has expected initial ordering based on settings', (defaultOrdering, field, dir) => {
setUp({ shortUrlsList: { defaultOrdering } });
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({
orderBy: { field, dir },
}));
});
it.each([
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
}), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
visits: { excludeBots: true },
}), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
}), '3.4.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
visits: { excludeBots: true },
}), '3.4.0' as SemVer, { field: 'nonBotVisits', dir: 'ASC' }],
])('parses order by based on server version and config', (settings, serverVersion, expectedOrderBy) => {
setUp(settings, serverVersion);
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ orderBy: expectedOrderBy }));
});
});

View file

@ -11,7 +11,7 @@ describe('<QrCodeModal />', () => {
const saveImage = jest.fn().mockReturnValue(Promise.resolve());
const QrCodeModal = createQrCodeModal(Mock.of<ImageDownloader>({ saveImage }));
const shortUrl = 'https://doma.in/abc123';
const setUp = (version: SemVer = '2.6.0') => renderWithEvents(
const setUp = (version: SemVer = '2.8.0') => renderWithEvents(
<QrCodeModal
isOpen
shortUrl={Mock.of<ShortUrl>({ shortUrl })}
@ -32,12 +32,10 @@ describe('<QrCodeModal />', () => {
});
it.each([
['2.5.0' as SemVer, 0, '/qr-code?size=300&format=png'],
['2.6.0' as SemVer, 0, '/qr-code?size=300&format=png'],
['2.6.0' as SemVer, 10, '/qr-code?size=300&format=png&margin=10'],
['2.8.0' as SemVer, 0, '/qr-code?size=300&format=png&errorCorrection=L'],
])('displays an image with the QR code of the URL', async (version, margin, expectedUrl) => {
const { container } = setUp(version);
[10, '/qr-code?size=300&format=png&errorCorrection=L&margin=10'],
[0, '/qr-code?size=300&format=png&errorCorrection=L'],
])('displays an image with the QR code of the URL', async (margin, expectedUrl) => {
const { container } = setUp();
const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1);
if (marginControl) {
@ -69,16 +67,13 @@ describe('<QrCodeModal />', () => {
modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`);
});
it.each([
['2.6.0' as SemVer, 1, 'col-md-4'],
['2.8.0' as SemVer, 2, 'col-md-6'],
])('shows expected components based on server version', (version, expectedAmountOfDropdowns, expectedRangeClass) => {
const { container } = setUp(version);
it('shows expected components based on server version', () => {
const { container } = setUp();
const dropdowns = screen.getAllByRole('button');
const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0);
expect(dropdowns).toHaveLength(expectedAmountOfDropdowns + 1); // Add one because of the close button
expect(firstCol).toHaveClass(expectedRangeClass);
expect(dropdowns).toHaveLength(2 + 1); // Add one because of the close button
expect(firstCol).toHaveClass('col-md-4');
});
it('saves the QR code image when clicking the Download button', async () => {

View file

@ -0,0 +1,48 @@
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react';
import { Mock } from 'ts-mockery';
import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus';
import { ShortUrl, ShortUrlMeta, ShortUrlVisitsSummary } from '../../../src/short-urls/data';
describe('<ShortUrlStatus />', () => {
const setUp = (shortUrl: ShortUrl) => ({
user: userEvent.setup(),
...render(<ShortUrlStatus shortUrl={shortUrl} />),
});
it.each([
[
Mock.of<ShortUrlMeta>({ validSince: '2099-01-01T10:30:15' }),
{},
'This short URL will start working on 2099-01-01 10:30.',
],
[
Mock.of<ShortUrlMeta>({ validUntil: '2020-01-01T10:30:15' }),
{},
'This short URL cannot be visited since 2020-01-01 10:30.',
],
[
Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
Mock.of<ShortUrlVisitsSummary>({ total: 10 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.',
],
[
Mock.of<ShortUrlMeta>({ maxVisits: 1 }),
Mock.of<ShortUrlVisitsSummary>({ total: 1 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.',
],
[{}, {}, 'This short URL can be visited normally.'],
[Mock.of<ShortUrlMeta>({ validUntil: '2099-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
[Mock.of<ShortUrlMeta>({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
[
Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
Mock.of<ShortUrlVisitsSummary>({ total: 1 }),
'This short URL can be visited normally.',
],
])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => {
const { user } = setUp(Mock.of<ShortUrl>({ meta, visitsSummary }));
await user.hover(screen.getByRole('img', { hidden: true }));
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent(expectedTooltip));
});
});

View file

@ -1,14 +1,18 @@
import { render } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Mock } from 'ts-mockery';
import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount';
import { ShortUrl } from '../../../src/short-urls/data';
describe('<ShortUrlVisitsCount />', () => {
const setUp = (visitsCount: number, shortUrl: ShortUrl) => render(
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />,
);
const setUp = (visitsCount: number, shortUrl: ShortUrl) => ({
user: userEvent.setup(),
...render(
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />,
),
});
it.each([undefined, {}])('just returns visits when no maxVisits is provided', (meta) => {
it.each([undefined, {}])('just returns visits when no limits are provided', (meta) => {
const visitsCount = 45;
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
@ -23,6 +27,30 @@ describe('<ShortUrlVisitsCount />', () => {
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
expect(container.firstChild).toHaveTextContent(`/ ${maxVisits}`);
expect(container.querySelector('.short-urls-visits-count__max-visits-control')).toBeInTheDocument();
});
it.each([
[['This short URL will not accept more than 50 visits'], { maxVisits: 50 }],
[['This short URL will not accept more than 1 visit'], { maxVisits: 1 }],
[['This short URL will not accept visits before 2022-01-01 10:00'], { validSince: '2022-01-01T10:00:00' }],
[['This short URL will not accept visits after 2022-05-05 15:30'], { validUntil: '2022-05-05T15:30:30' }],
[[
'This short URL will not accept more than 100 visits',
'This short URL will not accept visits after 2022-05-05 15:30',
], { validUntil: '2022-05-05T15:30:30', maxVisits: 100 }],
[[
'This short URL will not accept more than 100 visits',
'This short URL will not accept visits before 2023-01-01 10:00',
'This short URL will not accept visits after 2023-05-05 15:30',
], { validSince: '2023-01-01T10:00:00', validUntil: '2023-05-05T15:30:30', maxVisits: 100 }],
])('displays proper amount of tooltip list items', async (expectedListItems, meta) => {
const { user } = setUp(100, Mock.of<ShortUrl>({ meta }));
await user.hover(screen.getByRole('img', { hidden: true }));
await waitFor(() => expect(screen.getByRole('list')));
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(expectedListItems.length);
expectedListItems.forEach((text, index) => expect(items[index]).toHaveTextContent(text));
});
});

View file

@ -0,0 +1,21 @@
import { screen, waitFor } from '@testing-library/react';
import { ShortUrlsFilterDropdown } from '../../../src/short-urls/helpers/ShortUrlsFilterDropdown';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<ShortUrlsFilterDropdown />', () => {
const setUp = (supportsDisabledFiltering: boolean) => renderWithEvents(
<ShortUrlsFilterDropdown onChange={jest.fn()} supportsDisabledFiltering={supportsDisabledFiltering} />,
);
it.each([
[true, 3],
[false, 1],
])('displays proper amount of menu items', async (supportsDisabledFiltering, expectedItems) => {
const { user } = setUp(supportsDisabledFiltering);
await user.click(screen.getByRole('button', { name: 'Filters' }));
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
expect(screen.getAllByRole('menuitem')).toHaveLength(expectedItems);
});
});

View file

@ -1,15 +1,30 @@
import { screen } from '@testing-library/react';
import { last } from 'ramda';
import { Mock } from 'ts-mockery';
import { formatISO } from 'date-fns';
import { addDays, formatISO, subDays } from 'date-fns';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow';
import { TimeoutToggle } from '../../../src/utils/helpers/hooks';
import { ShortUrl } from '../../../src/short-urls/data';
import { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
import { Settings } from '../../../src/settings/reducers/settings';
import { ReachableServer } from '../../../src/servers/data';
import { parseDate } from '../../../src/utils/helpers/date';
import { parseDate, now } from '../../../src/utils/helpers/date';
import { renderWithEvents } from '../../__helpers__/setUpTest';
import { OptionalString } from '../../../src/utils/utils';
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
interface SetUpOptions {
title?: OptionalString;
tags?: string[];
meta?: ShortUrlMeta;
settings?: Partial<Settings>;
}
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockReturnValue({}),
}));
describe('<ShortUrlsRow />', () => {
const timeoutToggle = jest.fn(() => true);
const useTimeoutToggle = jest.fn(() => [false, timeoutToggle]) as TimeoutToggle;
@ -21,6 +36,11 @@ describe('<ShortUrlsRow />', () => {
dateCreated: formatISO(parseDate('2018-05-23 18:30:41', 'yyyy-MM-dd HH:mm:ss')),
tags: [],
visitsCount: 45,
visitsSummary: {
total: 45,
nonBots: 40,
bots: 5,
},
domain: null,
meta: {
validSince: null,
@ -29,20 +49,31 @@ describe('<ShortUrlsRow />', () => {
},
};
const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle);
const setUp = (title?: OptionalString, tags: string[] = []) => renderWithEvents(
<table>
<tbody>
<ShortUrlsRow selectedServer={server} shortUrl={{ ...shortUrl, title, tags }} onTagClick={() => null} />
</tbody>
</table>,
);
const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => {
(useLocation as any).mockReturnValue({ search });
return renderWithEvents(
<MemoryRouter>
<table>
<tbody>
<ShortUrlsRow
selectedServer={server}
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
onTagClick={() => null}
settings={Mock.of<Settings>(settings)}
/>
</tbody>
</table>
</MemoryRouter>,
);
};
it.each([
[null, 6],
[undefined, 6],
['The title', 7],
[null, 7],
[undefined, 7],
['The title', 8],
])('renders expected amount of columns', (title, expectedAmount) => {
setUp(title);
setUp({ title });
expect(screen.getAllByRole('cell')).toHaveLength(expectedAmount);
});
@ -67,7 +98,7 @@ describe('<ShortUrlsRow />', () => {
['My super cool title', 'My super cool title'],
[undefined, shortUrl.longUrl],
])('renders title when short URL has it', (title, expectedContent) => {
setUp(title);
setUp({ title });
const titleSharedCol = screen.getAllByRole('cell')[2];
@ -79,22 +110,56 @@ describe('<ShortUrlsRow />', () => {
[[], ['No tags']],
[['nodejs', 'reactjs'], ['nodejs', 'reactjs']],
])('renders list of tags in fourth row', (tags, expectedContents) => {
setUp(undefined, tags);
setUp({ tags });
const cell = screen.getAllByRole('cell')[3];
expectedContents.forEach((content) => expect(cell).toHaveTextContent(content));
});
it('renders visits count in fifth row', () => {
setUp();
expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${shortUrl.visitsCount}`);
it.each([
[{}, '', shortUrl.visitsSummary?.total],
[Mock.of<Settings>({ visits: { excludeBots: false } }), '', shortUrl.visitsSummary?.total],
[Mock.of<Settings>({ visits: { excludeBots: true } }), '', shortUrl.visitsSummary?.nonBots],
[Mock.of<Settings>({ visits: { excludeBots: false } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[Mock.of<Settings>({ visits: { excludeBots: true } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[{}, 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[Mock.of<Settings>({ visits: { excludeBots: true } }), 'excludeBots=false', shortUrl.visitsSummary?.total],
[Mock.of<Settings>({ visits: { excludeBots: false } }), 'excludeBots=false', shortUrl.visitsSummary?.total],
[{}, 'excludeBots=false', shortUrl.visitsSummary?.total],
])('renders visits count in fifth row', (settings, search, expectedAmount) => {
setUp({ settings }, search);
expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${expectedAmount}`);
});
it('updates state when copied to clipboard', async () => {
const { user } = setUp();
expect(timeoutToggle).not.toHaveBeenCalled();
await user.click(screen.getByRole('img', { hidden: true }));
await user.click(screen.getAllByRole('img', { hidden: true })[0]);
expect(timeoutToggle).toHaveBeenCalledTimes(1);
});
it.each([
[{ validUntil: formatISO(subDays(now(), 1)) }, ['fa-calendar-xmark', 'text-danger']],
[{ validSince: formatISO(addDays(now(), 1)) }, ['fa-calendar-xmark', 'text-warning']],
[{ maxVisits: 45 }, ['fa-link-slash', 'text-danger']],
[{ maxVisits: 45, validSince: formatISO(addDays(now(), 1)) }, ['fa-link-slash', 'text-danger']],
[
{ validSince: formatISO(addDays(now(), 1)), validUntil: formatISO(subDays(now(), 1)) },
['fa-calendar-xmark', 'text-danger'],
],
[
{ validSince: formatISO(subDays(now(), 1)), validUntil: formatISO(addDays(now(), 1)) },
['fa-check', 'text-primary'],
],
[{ maxVisits: 500 }, ['fa-check', 'text-primary']],
[{}, ['fa-check', 'text-primary']],
])('displays expected status icon', (meta, expectedIconClasses) => {
setUp({ meta });
const statusIcon = last(screen.getAllByRole('img', { hidden: true }));
expect(statusIcon).toBeInTheDocument();
expectedIconClasses.forEach((expectedClass) => expect(statusIcon).toHaveClass(expectedClass));
expect(statusIcon).toMatchSnapshot();
});
});

View file

@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { Tags } from '../../../src/short-urls/helpers/Tags';
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
describe('<Tags />', () => {
const setUp = (tags: string[]) => render(<Tags tags={tags} colorGenerator={colorGeneratorMock} />);
it('returns no tags when the list is empty', () => {
setUp([]);
expect(screen.getByText('No tags')).toBeInTheDocument();
});
it.each([
[['foo', 'bar', 'baz']],
[['one', 'two', 'three', 'four', 'five']],
])('returns expected tags based on provided list', (tags) => {
setUp(tags);
expect(screen.queryByText('No tags')).not.toBeInTheDocument();
tags.forEach((tag) => expect(screen.getByText(tag)).toBeInTheDocument());
});
});

View file

@ -0,0 +1,145 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ShortUrlsRow /> displays expected status icon 1`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-calendar-xmark text-danger"
data-icon="calendar-xmark"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> displays expected status icon 2`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-calendar-xmark text-warning"
data-icon="calendar-xmark"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> displays expected status icon 3`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-link-slash text-danger"
data-icon="link-slash"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5L239 162zM406.6 416.4L220.9 270c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3zm-290-228.5L60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> displays expected status icon 4`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-link-slash text-danger"
data-icon="link-slash"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5L239 162zM406.6 416.4L220.9 270c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3zm-290-228.5L60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> displays expected status icon 5`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-calendar-xmark text-danger"
data-icon="calendar-xmark"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> displays expected status icon 6`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-check text-primary"
data-icon="check"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> displays expected status icon 7`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-check text-primary"
data-icon="check"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> displays expected status icon 8`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-check text-primary"
data-icon="check"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
fill="currentColor"
/>
</svg>
`;

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