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: ci:
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
with: with:
node-version: 16.15 node-version: 18.12
with-mutation-tests: true with-mutation-tests: true
publish-coverage: true publish-coverage: true
force-install: true force-install: true

View file

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

View file

@ -14,7 +14,7 @@ jobs:
- name: Use node.js - name: Use node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 16.15 node-version: 18.12
- name: Generate release assets - name: Generate release assets
run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets - 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). 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 ## [3.8.2] - 2022-12-17
### Added ### Added
* *Nothing* * *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 COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION ${VERSION} 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>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf 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 COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf

View file

@ -1,6 +1,6 @@
# shlink-web-client # 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) [![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) [![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/) [![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 = { module.exports = {
presets: [ presets: [
[ ['@babel/preset-env', {
'react-app', targets: { esmodules: true }
{ }],
runtime: 'automatic', ['@babel/preset-react', { runtime: 'automatic' }],
typescript: true, '@babel/preset-typescript',
},
],
], ],
}; };

View file

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

View file

@ -3,8 +3,8 @@ version: '3'
services: services:
shlink_web_client_node: shlink_web_client_node:
container_name: shlink_web_client_node container_name: shlink_web_client_node
image: node:16.15-alpine image: node:18.12-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start" command: /bin/sh -c "cd /home/shlink/www && npm install --force && npm run start"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www
ports: 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',
},
],
};

27765
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:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix", "lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix", "lint:js:fix": "npm run lint:js -- --fix",
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start", "start": "vite serve --host=0.0.0.0",
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.mjs", "build": "tsc --noEmit && vite build && node scripts/replace-version.mjs",
"build:dist": "npm run build && node scripts/create-dist-file.mjs", "build:dist": "npm run build && node scripts/create-dist-file.mjs",
"build:serve": "serve -p 5000 ./build", "build:serve": "serve -p 5000 ./build",
"test": "jest --env=jsdom --colors", "test": "jest --env=jsdom --colors",
@ -24,43 +24,45 @@
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic" "mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.2.0", "@babel/preset-env": "^7.20.2",
"@fortawesome/fontawesome-svg-core": "^6.2.0", "@babel/preset-react": "^7.18.6",
"@fortawesome/free-regular-svg-icons": "^6.2.0", "@babel/preset-typescript": "^7.18.6",
"@fortawesome/free-solid-svg-icons": "^6.2.0", "@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", "@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "^1.9.0", "@json2csv/plainjs": "^6.1.2",
"bootstrap": "^5.2.2", "@reduxjs/toolkit": "^1.9.1",
"bootstrap": "^5.2.3",
"bottlejs": "^2.0.1", "bottlejs": "^2.0.1",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"chart.js": "^3.9.1", "chart.js": "^4.1.1",
"classnames": "^2.3.1", "classnames": "^2.3.2",
"compare-versions": "^5.0.1", "compare-versions": "^5.0.3",
"csvtojson": "^2.0.10", "csvtojson": "^2.0.10",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
"history": "^5.3.0", "history": "^5.3.0",
"json2csv": "^5.0.7", "leaflet": "^1.9.3",
"leaflet": "^1.9.2",
"qs": "^6.11.0", "qs": "^6.11.0",
"ramda": "^0.27.2", "ramda": "^0.27.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^4.3.1", "react-chartjs-2": "^5.1.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"react-datepicker": "^4.8.0", "react-datepicker": "^4.8.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-external-link": "^2.0.0", "react-external-link": "^2.0.0",
"react-leaflet": "^4.1.0", "react-leaflet": "^4.2.0",
"react-redux": "^8.0.4", "react-redux": "^8.0.5",
"react-router-dom": "^6.4.1", "react-router-dom": "^6.6.1",
"react-swipeable": "^7.0.0", "react-swipeable": "^7.0.0",
"react-tag-autocomplete": "^6.3.0", "react-tag-autocomplete": "^6.3.0",
"reactstrap": "^9.1.4", "reactstrap": "^9.1.5",
"redux": "^4.2.0", "redux": "^4.2.0",
"redux-localstorage-simple": "^2.5.1", "redux-localstorage-simple": "^2.5.1",
"redux-thunk": "^2.4.1", "redux-thunk": "^2.4.2",
"stream": "^0.0.2",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"workbox-core": "^6.5.4", "workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4", "workbox-expiration": "^6.5.4",
@ -71,41 +73,42 @@
"devDependencies": { "devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2", "@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1", "@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
"@stryker-mutator/core": "^6.2.2", "@stryker-mutator/core": "^6.3.1",
"@stryker-mutator/jest-runner": "^6.2.2", "@stryker-mutator/jest-runner": "^6.3.1",
"@stryker-mutator/typescript-checker": "^6.2.2", "@stryker-mutator/typescript-checker": "^6.3.1",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.1.1", "@types/jest": "^29.2.4",
"@types/json2csv": "^5.0.3", "@types/json2csv": "^5.0.3",
"@types/leaflet": "^1.8.0", "@types/leaflet": "^1.9.0",
"@types/qs": "^6.9.7", "@types/qs": "^6.9.7",
"@types/ramda": "^0.28.15", "@types/ramda": "^0.28.15",
"@types/react": "^18.0.21", "@types/react": "^18.0.26",
"@types/react-color": "^3.0.6", "@types/react-color": "^3.0.6",
"@types/react-copy-to-clipboard": "^5.0.4", "@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-datepicker": "^4.4.2", "@types/react-datepicker": "^4.8.0",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.10",
"@types/react-tag-autocomplete": "^6.3.0", "@types/react-tag-autocomplete": "^6.3.0",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"adm-zip": "^0.5.9", "@vitejs/plugin-react": "^3.0.0",
"babel-jest": "^29.1.2", "adm-zip": "^0.5.10",
"chalk": "^5.0.1", "babel-jest": "^29.3.1",
"eslint": "^8.24.0", "chalk": "^5.2.0",
"eslint": "^8.30.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^29.1.2", "jest": "^29.3.1",
"jest-canvas-mock": "^2.4.0", "jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^29.1.2", "jest-environment-jsdom": "^29.3.1",
"react-scripts": "^5.0.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"sass": "^1.55.0", "sass": "^1.57.1",
"serve": "^14.1.1", "serve": "^14.1.2",
"stryker-cli": "^1.0.2", "stryker-cli": "^1.0.2",
"stylelint": "^14.13.0", "stylelint": "^14.16.0",
"ts-mockery": "^1.2.0", "ts-mockery": "^1.2.0",
"typescript": "^4.8.4", "typescript": "^4.9.4",
"webpack": "^5.74.0" "vite": "^4.0.3",
"vite-plugin-pwa": "^0.14.0"
}, },
"browserslist": [ "browserslist": [
">0.2%", ">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'; import fs from 'fs';
function replaceVersionPlaceholder(version) { function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js'; const staticJsFilesPath = './build/assets';
const versionPlaceholder = '%_VERSION_%'; const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js'); const isMainFile = (file) => file.startsWith('index-') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile); const [mainJsFile] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`; const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8'); const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version); 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 module 'event-source-polyfill' {
declare class EventSourcePolyfill { declare class EventSourcePolyfill {
public onmessage?: ({ data }: { data: string }) => void; 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' 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 buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`;
const rejectNilProps = reject(isNil); const rejectNilProps = reject(isNil);
const normalizeOrderByInParams = ( const normalizeListParams = (
{ orderBy = {}, ...rest }: ShlinkShortUrlsListParams, { orderBy = {}, excludeMaxVisitsReached, excludePastValidUntil, ...rest }: ShlinkShortUrlsListParams,
): ShlinkShortUrlsListNormalizedParams => ({ ...rest, orderBy: orderToString(orderBy) }); ): ShlinkShortUrlsListNormalizedParams => ({
...rest,
excludeMaxVisitsReached: excludeMaxVisitsReached === true ? 'true' : undefined,
excludePastValidUntil: excludePastValidUntil === true ? 'true' : undefined,
orderBy: orderToString(orderBy),
});
export class ShlinkApiClient { export class ShlinkApiClient {
private apiVersion: 2 | 3; private apiVersion: 2 | 3;
@ -40,7 +45,7 @@ export class ShlinkApiClient {
} }
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> => 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); .then(({ shortUrls }) => shortUrls);
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => { public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {

View file

@ -1,6 +1,7 @@
import { Visit } from '../../visits/types'; import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils'; 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 { export interface ShlinkShortUrlsResponse {
data: ShortUrl[]; data: ShortUrl[];
@ -88,17 +89,26 @@ export interface ShlinkDomainsResponse {
export type TagsFilteringMode = 'all' | 'any'; export type TagsFilteringMode = 'all' | 'any';
type ShlinkShortUrlsOrderableFields = 'dateCreated' | 'shortCode' | 'longUrl' | 'title' | 'visits' | 'nonBotVisits';
export type ShlinkShortUrlsOrder = Order<ShlinkShortUrlsOrderableFields>;
export interface ShlinkShortUrlsListParams { export interface ShlinkShortUrlsListParams {
page?: string; page?: string;
itemsPerPage?: number; itemsPerPage?: number;
tags?: string[];
searchTerm?: string; searchTerm?: string;
tags?: string[];
tagsMode?: TagsFilteringMode;
orderBy?: ShlinkShortUrlsOrder;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
orderBy?: ShortUrlsOrder; excludeMaxVisitsReached?: boolean;
tagsMode?: TagsFilteringMode; excludePastValidUntil?: boolean;
} }
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> { export interface ShlinkShortUrlsListNormalizedParams extends
Omit<ShlinkShortUrlsListParams, 'orderBy' | 'excludeMaxVisitsReached' | 'excludePastValidUntil'> {
orderBy?: string; 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 classNames from 'classnames';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { isServerWithId, SelectedServer } from '../servers/data'; import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features';
import './AsideMenu.scss'; import './AsideMenu.scss';
export interface AsideMenuProps { export interface AsideMenuProps {
@ -22,6 +21,7 @@ export interface AsideMenuProps {
interface AsideMenuItemProps extends NavLinkProps { interface AsideMenuItemProps extends NavLinkProps {
to: string; to: string;
className?: string;
} }
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => ( const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
@ -40,7 +40,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
const hasId = isServerWithId(selectedServer); const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : ''; const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation(); const { pathname } = useLocation();
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', { const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
}); });
@ -68,12 +67,10 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={tagsIcon} /> <FontAwesomeIcon fixedWidth icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span> <span className="aside-menu__item-text">Manage tags</span>
</AsideMenuItem> </AsideMenuItem>
{addManageDomainsLink && (
<AsideMenuItem to={buildPath('/manage-domains')}> <AsideMenuItem to={buildPath('/manage-domains')}>
<FontAwesomeIcon fixedWidth icon={domainsIcon} /> <FontAwesomeIcon fixedWidth icon={domainsIcon} />
<span className="aside-menu__item-text">Manage domains</span> <span className="aside-menu__item-text">Manage domains</span>
</AsideMenuItem> </AsideMenuItem>
)}
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push"> <AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon fixedWidth icon={editIcon} /> <FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span> <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 classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSwipeable, useToggle } from '../utils/helpers/hooks'; import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features'; import { supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import { AsideMenuProps } from './AsideMenu'; import { AsideMenuProps } from './AsideMenu';
@ -38,7 +38,6 @@ export const MenuLayout = (
useEffect(() => hideSidebar(), [location]); useEffect(() => hideSidebar(), [location]);
useEffect(() => { useEffect(() => {
showContent && sidebarPresent(); showContent && sidebarPresent();
return () => sidebarNotPresent(); return () => sidebarNotPresent();
}, []); }, []);
@ -47,7 +46,6 @@ export const MenuLayout = (
} }
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer); const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const addDomainVisitsRoute = supportsDomainVisits(selectedServer); const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar); const swipeableProps = useSwipeable(showSidebar, hideSidebar);
@ -73,7 +71,7 @@ export const MenuLayout = (
<Route path="/orphan-visits/*" element={<OrphanVisits />} /> <Route path="/orphan-visits/*" element={<OrphanVisits />} />
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />} {addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} /> <Route path="/manage-tags" element={<TagsList />} />
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />} <Route path="/manage-domains" element={<ManageDomains />} />
<Route <Route
path="*" path="*"
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>} 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) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services // Services
bottle.constant('window', (global as any).window); bottle.constant('window', window);
bottle.constant('console', global.console); bottle.constant('console', console);
bottle.constant('fetch', (global as any).fetch.bind(global)); bottle.constant('fetch', window.fetch.bind(window));
bottle.service('HttpClient', HttpClient, 'fetch'); bottle.service('HttpClient', HttpClient, 'fetch');
bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window'); bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window');

View file

@ -1,7 +1,7 @@
import { FC } from 'react'; import { FC } from 'react';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data'; import { isServerWithId, ServerData } from './data';
@ -10,8 +10,11 @@ interface EditServerProps {
editServer: (serverId: string, serverData: ServerData) => void; 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 goBack = useGoBack();
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
if (!isServerWithId(selectedServer)) { if (!isServerWithId(selectedServer)) {
return null; return null;
@ -19,6 +22,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
const handleSubmit = (serverData: ServerData) => { const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData); editServer(selectedServer.id, serverData);
reconnect === 'true' && selectServer(selectedServer.id);
goBack(); 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 { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList'; 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 { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsOverview } from '../visits/reducers/visitsOverview';
@ -25,7 +25,7 @@ interface OverviewConnectProps {
} }
export const Overview = ( export const Overview = (
ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsTable: ShortUrlsTableType,
CreateShortUrl: FC<CreateShortUrlProps>, CreateShortUrl: FC<CreateShortUrlProps>,
) => boundToMercureHub(({ ) => boundToMercureHub(({
shortUrlsList, shortUrlsList,

View file

@ -37,7 +37,7 @@ export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC
<h5> <h5>
Alternatively, if you think you may have miss-configured this server, you 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; 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> </h5>
</div> </div>
)} )}

View file

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

View file

@ -12,12 +12,13 @@ import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing'; import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies'; import { StaleWhileRevalidate } from 'workbox-strategies';
import pack from '../package.json';
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
clientsClaim(); 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. // Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file, // This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA // 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 to signal that we want to use the handler.
return true; 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 // 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 // To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA // opt-in, read https://cra.link/PWA
import pack from'../package.json';
const isLocalhost = Boolean( const isLocalhost = Boolean(
window.location.hostname === 'localhost' || window.location.hostname === 'localhost' ||
@ -26,7 +27,7 @@ type Config = {
export function register(config?: Config) { export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href); const publicUrl = new URL(pack.homepage, window.location.href);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin // Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to // from what our page is served on. This might happen if a CDN is used to
@ -35,7 +36,7 @@ export function register(config?: Config) {
} }
window.addEventListener('load', () => { window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; const swUrl = `${pack.homepage}/service-worker.js`;
if (isLocalhost) { if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not. // 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 { FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { OrderingDropdown } from '../utils/OrderingDropdown'; import { OrderingDropdown } from '../utils/OrderingDropdown';
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings'; import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
@ -15,14 +12,6 @@ interface TagsProps {
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => ( export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
<SimpleCard title="Tags" className="h-100"> <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:"> <LabeledFormGroup noMargin label="Default ordering for tags list:">
<OrderingDropdown <OrderingDropdown
items={TAGS_ORDERABLE_FIELDS} items={TAGS_ORDERABLE_FIELDS}

View file

@ -1,20 +1,39 @@
import { FC } from 'react'; import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector'; import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings'; 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 { interface VisitsProps {
settings: Settings; settings: Settings;
setVisitsSettings: (settings: VisitsSettingsConfig) => void; setVisitsSettings: (settings: VisitsSettingsConfig) => void;
} }
const currentDefaultInterval = (settings: Settings): DateInterval => settings.visits?.defaultInterval ?? 'last30Days';
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => ( export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<SimpleCard title="Visits" className="h-100"> <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:"> <LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<DateIntervalSelector <DateIntervalSelector
allText="All visits" allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'} active={currentDefaultInterval(settings)}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })} onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/> />
</LabeledFormGroup> </LabeledFormGroup>

View file

@ -11,12 +11,5 @@ export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days'); 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; return state;
}; };

View file

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

View file

@ -21,7 +21,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`; `/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
if (pagesCount <= 1) { if (pagesCount <= 1) {
return null; return <div className="pb-3" />; // Return some space
} }
const renderPages = () => const renderPages = () =>
@ -38,7 +38,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
)); ));
return ( 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}> <PaginationItem disabled={currentPage === 1}>
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} /> <PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
</PaginationItem> </PaginationItem>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,9 @@ export interface ShortUrl {
shortUrl: string; shortUrl: string;
longUrl: string; longUrl: string;
dateCreated: 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>>; meta: Required<Nullable<ShortUrlMeta>>;
tags: string[]; tags: string[];
domain: string | null; domain: string | null;
@ -46,6 +48,12 @@ export interface ShortUrlMeta {
maxVisits?: number; maxVisits?: number;
} }
export interface ShortUrlVisitsSummary {
total: number;
nonBots: number;
bots: number;
}
export interface ShortUrlModalProps { export interface ShortUrlModalProps {
shortUrl: ShortUrl; shortUrl: ShortUrl;
isOpen: boolean; isOpen: boolean;
@ -72,3 +80,9 @@ export interface ExportableShortUrl {
tags: string; tags: string;
visits: number; visits: number;
} }
export interface ShortUrlsFilter {
excludeBots?: boolean;
excludeMaxVisitsReached?: boolean;
excludePastValidUntil?: boolean;
}

View file

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

View file

@ -6,8 +6,8 @@ import { ExternalLink } from 'react-external-link';
import { ShortUrlModalProps } from '../data'; import { ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { supportsNonRestCors, supportsQrErrorCorrection } from '../../utils/helpers/features'; import { supportsNonRestCors } from '../../utils/helpers/features';
import { ImageDownloader } from '../../common/services/ImageDownloader'; import { ImageDownloader } from '../../common/services/ImageDownloader';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
@ -24,14 +24,10 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
const [margin, setMargin] = useState(0); const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png'); const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L'); const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const capabilities: QrCodeCapabilities = useMemo(() => ({
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
}), [selectedServer]);
const displayDownloadBtn = supportsNonRestCors(selectedServer); const displayDownloadBtn = supportsNonRestCors(selectedServer);
const willRenderThreeControls = !capabilities.errorCorrectionIsSupported;
const qrCodeUrl = useMemo( const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection, capabilities], [shortUrl, size, format, margin, errorCorrection],
); );
const totalSize = useMemo(() => size + margin, [size, margin]); const totalSize = useMemo(() => size + margin, [size, margin]);
const modalSize = useMemo(() => { const modalSize = useMemo(() => {
@ -49,7 +45,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Row> <Row>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}> <FormGroup className="d-grid col-md-4">
<label>Size: {size}px</label> <label>Size: {size}px</label>
<input <input
type="range" type="range"
@ -61,7 +57,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setSize(Number(e.target.value))} onChange={(e) => setSize(Number(e.target.value))}
/> />
</FormGroup> </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> <label htmlFor="marginControl">Margin: {margin}px</label>
<input <input
id="marginControl" id="marginControl"
@ -74,14 +70,12 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setMargin(Number(e.target.value))} onChange={(e) => setMargin(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}> <FormGroup className="d-grid col-md-4">
<QrFormatDropdown format={format} setFormat={setFormat} /> <QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup> </FormGroup>
{capabilities.errorCorrectionIsSupported && (
<FormGroup className="col-md-6"> <FormGroup className="col-md-6">
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} /> <QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
</FormGroup> </FormGroup>
)}
</Row> </Row>
<div className="text-center"> <div className="text-center">
<div className="mb-3"> <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 { .short-url-visits-count__amount--big {
transform: scale(1.5); 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 { ShortUrl } from '../data';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { ShortUrlDetailLink } from './ShortUrlDetailLink'; import { ShortUrlDetailLink } from './ShortUrlDetailLink';
import './ShortUrlVisitsCount.scss';
import { mutableRefToElementRef } from '../../utils/helpers/components'; import { mutableRefToElementRef } from '../../utils/helpers/components';
import { formatHumanFriendly, parseISO } from '../../utils/helpers/date';
import './ShortUrlVisitsCount.scss';
interface ShortUrlVisitsCountProps { interface ShortUrlVisitsCountProps {
shortUrl?: ShortUrl | null; shortUrl?: ShortUrl | null;
@ -20,7 +21,8 @@ interface ShortUrlVisitsCountProps {
export const ShortUrlVisitsCount = ( export const ShortUrlVisitsCount = (
{ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps, { visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps,
) => { ) => {
const maxVisits = shortUrl?.meta?.maxVisits; const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {};
const hasLimit = !!maxVisits || !!validSince || !!validUntil;
const visitsLink = ( const visitsLink = (
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits"> <ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<strong <strong
@ -31,29 +33,43 @@ export const ShortUrlVisitsCount = (
</ShortUrlDetailLink> </ShortUrlDetailLink>
); );
if (!maxVisits) { if (!hasLimit) {
return visitsLink; return visitsLink;
} }
const prettifiedMaxVisits = prettify(maxVisits);
const tooltipRef = useRef<HTMLElement | undefined>(); const tooltipRef = useRef<HTMLElement | undefined>();
return ( return (
<> <>
<span className="indivisible"> <span className="indivisible">
{visitsLink} {visitsLink}
<small <small className="short-urls-visits-count__max-visits-control" ref={mutableRefToElementRef(tooltipRef)}>
className="short-urls-visits-count__max-visits-control" {maxVisits && <> / {prettify(maxVisits)}</>}
ref={mutableRefToElementRef(tooltipRef)} <sup className="ms-1">
>
{' '}/ {prettifiedMaxVisits}{' '}
<sup>
<FontAwesomeIcon icon={infoIcon} /> <FontAwesomeIcon icon={infoIcon} />
</sup> </sup>
</small> </small>
</span> </span>
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom"> <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> </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; word-break: break-all;
} }
.short-urls-row__cell--relative {
position: relative;
}
.short-urls-row__cell--indivisible { .short-urls-row__cell--indivisible {
@media (min-width: $lgMin) { @media (min-width: $lgMin) {
white-space: nowrap; white-space: nowrap;

View file

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

View file

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

View file

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

View file

@ -28,7 +28,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
)); ));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
bottle.decorator('ShortUrlsRow', connect(['settings']));
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal'); bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector'); 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 { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Topics } from '../mercure/helpers/Topics'; 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 { determineOrderDir, sortList } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown'; import { OrderingDropdown } from '../utils/OrderingDropdown';
import { TagsList as TagsListState } from './reducers/tagsList'; import { TagsList as TagsListState } from './reducers/tagsList';
import { import { TagsOrderableFields, TAGS_ORDERABLE_FIELDS, TagsOrder } from './data/TagsListChildrenProps';
TagsOrderableFields,
TAGS_ORDERABLE_FIELDS,
TagsListChildrenProps,
TagsOrder,
} from './data/TagsListChildrenProps';
import { TagsModeDropdown } from './TagsModeDropdown';
import { NormalizedTag } from './data'; import { NormalizedTag } from './data';
import { TagsTableProps } from './TagsTable'; import { TagsTableProps } from './TagsTable';
@ -30,10 +24,9 @@ export interface TagsListProps {
settings: Settings; 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, { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => { ) => {
const [mode, setMode] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards');
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {}); const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
const resolveSortedTags = pipe( const resolveSortedTags = pipe(
() => tagsList.filteredTags.map((tag): NormalizedTag => ({ () => tagsList.filteredTags.map((tag): NormalizedTag => ({
@ -73,9 +66,7 @@ export const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<Tag
const sortedTags = resolveSortedTags(); const sortedTags = resolveSortedTags();
return mode === 'cards' return (
? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
: (
<TagsTable <TagsTable
sortedTags={sortedTags} sortedTags={sortedTags}
selectedServer={selectedServer} selectedServer={selectedServer}
@ -89,10 +80,7 @@ export const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<Tag
<> <>
<SearchField className="mb-3" onChange={filterTags} /> <SearchField className="mb-3" onChange={filterTags} />
<Row className="mb-3"> <Row className="mb-3">
<div className="col-lg-6"> <div className="col-lg-6 offset-lg-6">
<TagsModeDropdown mode={mode} onChange={setMode} />
</div>
<div className="col-lg-6 mt-3 mt-lg-0">
<OrderingDropdown <OrderingDropdown
items={TAGS_ORDERABLE_FIELDS} items={TAGS_ORDERABLE_FIELDS}
order={order} 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 { prop } from 'ramda';
import Bottle, { IContainer } from 'bottlejs'; import Bottle, { IContainer } from 'bottlejs';
import { TagsSelector } from '../helpers/TagsSelector'; import { TagsSelector } from '../helpers/TagsSelector';
import { TagCard } from '../TagCard';
import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal'; import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal';
import { EditTagModal } from '../helpers/EditTagModal'; import { EditTagModal } from '../helpers/EditTagModal';
import { TagsList } from '../TagsList'; import { TagsList } from '../TagsList';
@ -9,7 +8,6 @@ import { filterTags, listTags, tagsListReducerCreator } from '../reducers/tagsLi
import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete'; import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete';
import { editTag, tagEdited, tagEditReducerCreator } from '../reducers/tagEdit'; import { editTag, tagEdited, tagEditReducerCreator } from '../reducers/tagEdit';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { TagsCards } from '../TagsCards';
import { TagsTable } from '../TagsTable'; import { TagsTable } from '../TagsTable';
import { TagsTableRow } from '../TagsTableRow'; import { TagsTableRow } from '../TagsTableRow';
@ -18,20 +16,17 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator'); bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect(['tagsList', 'settings'], ['listTags'])); bottle.decorator('TagsSelector', connect(['tagsList', 'settings'], ['listTags']));
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal); bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect(['tagDelete'], ['deleteTag', 'tagDeleted'])); bottle.decorator('DeleteTagConfirmModal', connect(['tagDelete'], ['deleteTag', 'tagDeleted']));
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator'); bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited'])); bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited']));
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow'); bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable'); bottle.serviceFactory('TagsList', TagsList, 'TagsTable');
bottle.decorator('TagsList', connect( bottle.decorator('TagsList', connect(
['tagsList', 'selectedServer', 'mercureInfo', 'settings'], ['tagsList', 'selectedServer', 'mercureInfo', 'settings'],
['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'], ['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'],

View file

@ -1,5 +1,5 @@
import csv from 'csvtojson'; import csv from 'csvtojson';
import { parse } from 'json2csv'; import { Parser } from '@json2csv/plainjs';
export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) => { export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) => {
csv().fromString(csvContent).then(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 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; export type JsonToCsv = typeof jsonToCsv;

View file

@ -30,6 +30,8 @@ export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date,
export const formatInternational = formatDate(); 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 parseDate = (date: string, theFormat: string) => parse(date, theFormat, now());
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date)); 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 => const serverMatchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: SelectedServer): boolean =>
isReachableServer(selectedServer) && versionMatch(selectedServer.version, { minVersion }); 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 supportsForwardQuery = serverMatchesMinVersion('2.9.0');
export const supportsNonRestCors = supportsForwardQuery; export const supportsNonRestCors = supportsForwardQuery;
export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0'); export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0');
export const supportsNonOrphanVisits = serverMatchesMinVersion('3.0.0'); export const supportsNonOrphanVisits = serverMatchesMinVersion('3.0.0');
export const supportsAllTagsFiltering = supportsNonOrphanVisits; export const supportsAllTagsFiltering = supportsNonOrphanVisits;
export const supportsDomainVisits = serverMatchesMinVersion('3.1.0'); 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 { useState, useRef, EffectCallback, DependencyList, useEffect } from 'react';
import { useSwipeable as useReactSwipeable } from 'react-swipeable'; 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 { v4 as uuid } from 'uuid';
import { parseQuery, stringifyQuery } from './query'; import { parseQuery, stringifyQuery } from './query';
@ -82,6 +82,11 @@ export const useGoBack = () => {
return () => navigate(-1); return () => navigate(-1);
}; };
export const useParsedQuery = <T>(): T => {
const { search } = useLocation();
return parseQuery<T>(search);
};
export const useDomId = (): string => { export const useDomId = (): string => {
const { current: id } = useRef(`dom-${uuid()}`); const { current: id } = useRef(`dom-${uuid()}`);
return id; return id;

View file

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

View file

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

View file

@ -15,10 +15,13 @@
.responsive-table__row { .responsive-table__row {
@media (max-width: $responsiveTableBreakpoint) { @media (max-width: $responsiveTableBreakpoint) {
display: block; display: block;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
border-top: 2px solid var(--border-color); border-top: 2px solid var(--border-color);
position: relative; 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 capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
export const equals = (value: any) => (otherValue: any) => value === otherValue; 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, domainVisits,
cancelGetDomainVisits, cancelGetDomainVisits,
settings, settings,
selectedServer,
}: DomainVisitsProps) => { }: DomainVisitsProps) => {
const goBack = useGoBack(); const goBack = useGoBack();
const { domain = '' } = useParams(); const { domain = '' } = useParams();
@ -38,7 +37,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure
visitsInfo={domainVisits} visitsInfo={domainVisits}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} /> <VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} />
</VisitsStats> </VisitsStats>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,18 @@
import { DeepPartial } from '@reduxjs/toolkit'; import { DeepPartial } from '@reduxjs/toolkit';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useMemo } from 'react'; 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 { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals';
import { OrphanVisitType, VisitsFilter } from '../types'; import { OrphanVisitType, VisitsFilter } from '../types';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { formatIsoDate } from '../../utils/helpers/date'; import { formatIsoDate } from '../../utils/helpers/date';
import { BooleanString, parseBooleanToString } from '../../utils/utils';
interface VisitsQuery { interface VisitsQuery {
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
orphanVisitsType?: OrphanVisitType; orphanVisitsType?: OrphanVisitType;
excludeBots?: 'true'; excludeBots?: BooleanString;
domain?: string; domain?: string;
} }
@ -38,7 +39,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
domain, domain,
filtering: { filtering: {
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined, 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 updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra); const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
const { excludeBots, orphanVisitsType } = visitsFilter;
const query: VisitsQuery = { const query: VisitsQuery = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '', startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '', endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: visitsFilter.excludeBots ? 'true' : undefined, excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
orphanVisitsType: visitsFilter.orphanVisitsType, orphanVisitsType,
domain: theDomain, domain: theDomain,
}; };
const stringifiedQuery = stringifyQuery(query); 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>( export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
{ typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>, { typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
) => { ) => {
const progressChangedAction = createAction<number>(`${typePrefix}/progressChanged`); const progressChanged = createAction<number>(`${typePrefix}/progressChanged`);
const largeAction = createAction<void>(`${typePrefix}/large`); const large = createAction<void>(`${typePrefix}/large`);
const fallbackToIntervalAction = createAction<DateInterval>(`${typePrefix}/fallbackToInterval`); 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 [visitsLoader, lastVisitLoader] = createLoaders(params, getState);
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> => 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]); 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) { if (index < pagesBlocks.length - 1) {
return data.concat(await loadPagesBlocks(pagesBlocks, index + 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); const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
dispatch(largeAction()); dispatch(large());
} }
return data.concat(await loadPagesBlocks(pagesBlocks)); 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()]); const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]);
if (!visits.length && lastVisit) { 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 = ( 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>>( export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
{ name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions<State, AT>, { 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({ const { reducer, actions } = createSlice({
name, name,
initialState, initialState,
@ -115,17 +116,17 @@ export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnT
cancelGetVisits: (state) => ({ ...state, cancelLoad: true }), cancelGetVisits: (state) => ({ ...state, cancelLoad: true }),
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(asyncThunk.pending, () => ({ ...initialState, loading: true })); builder.addCase(pending, () => ({ ...initialState, loading: true }));
builder.addCase(asyncThunk.rejected, (_, { error }) => ( builder.addCase(rejected, (_, { error }) => (
{ ...initialState, error: true, errorData: parseApiError(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 } { ...state, ...payload, loading: false, loadingLarge: false, error: false }
)); ));
builder.addCase(largeAction, (state) => ({ ...state, loadingLarge: true })); builder.addCase(large, (state) => ({ ...state, loadingLarge: true }));
builder.addCase(progressChangedAction, (state, { payload: progress }) => ({ ...state, progress })); builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress }));
builder.addCase(fallbackToIntervalAction, (state, { payload: fallbackInterval }) => ( builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => (
{ ...state, fallbackInterval } { ...state, fallbackInterval }
)); ));

View file

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

View file

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

View file

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

View file

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

View file

@ -46,6 +46,28 @@ describe('ShlinkApiClient', () => {
expect.anything(), 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', () => { describe('createShortUrl', () => {

View file

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

View file

@ -77,7 +77,6 @@ describe('<MenuLayout />', () => {
['3.1.0' as SemVer, '/domain/domain.com/visits/foo', 'DomainVisits'], ['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.'], ['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'], ['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'], ['2.8.0' as SemVer, '/manage-domains', 'ManageDomains'],
])( ])(
'renders expected component based on location and server version', 'renders expected component based on location and server version',

View file

@ -1,6 +1,6 @@
import { fireEvent, screen } from '@testing-library/react'; import { fireEvent, screen } from '@testing-library/react';
import { Mock } from 'ts-mockery'; 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 { EditServer as editServerConstruct } from '../../src/servers/EditServer';
import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
@ -19,7 +19,9 @@ describe('<EditServer />', () => {
}); });
const EditServer = editServerConstruct(ServerError); const EditServer = editServerConstruct(ServerError);
const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => renderWithEvents( 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(() => { beforeEach(() => {

View file

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

View file

@ -1,9 +1,8 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { Mock } from 'ts-mockery'; 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 { TagsSettings } from '../../src/settings/TagsSettings';
import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps'; import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps';
import { capitalize } from '../../src/utils/utils';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<TagsSettings />', () => { describe('<TagsSettings />', () => {
@ -17,35 +16,10 @@ describe('<TagsSettings />', () => {
it('renders expected amount of groups', () => { it('renders expected amount of groups', () => {
setUp(); setUp();
expect(screen.getByText('Default display mode when managing tags:')).toBeInTheDocument();
expect(screen.getByText('Default ordering for tags list:')).toBeInTheDocument(); expect(screen.getByText('Default ordering for tags list:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Order by...' })).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([ it.each([
[undefined, 'Order by...'], [undefined, 'Order by...'],
[{}, 'Order by...'], [{}, 'Order by...'],

View file

@ -17,6 +17,7 @@ describe('<VisitsSettings />', () => {
expect(screen.getByRole('heading')).toHaveTextContent('Visits'); expect(screen.getByRole('heading')).toHaveTextContent('Visits');
expect(screen.getByText('Default interval to load on visits sections:')).toBeInTheDocument(); expect(screen.getByText('Default interval to load on visits sections:')).toBeInTheDocument();
expect(screen.getByText(/^Exclude bots wherever possible/)).toBeInTheDocument();
}); });
it.each([ it.each([
@ -59,4 +60,36 @@ describe('<VisitsSettings />', () => {
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' });
expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' }); 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: { visits: {
defaultInterval: 'last180days' as any, defaultInterval: 'last180days' as any,
}, },
ui: {
tagsMode: 'list',
} as any,
}, },
}); });
@ -25,9 +22,6 @@ describe('settings-helpers', () => {
visits: { visits: {
defaultInterval: 'last180Days', defaultInterval: 'last180Days',
}, },
tags: {
defaultMode: 'list',
},
}), }),
})); }));
}); });

View file

@ -18,9 +18,11 @@ describe('<Paginator />', () => {
[buildPaginator()], [buildPaginator()],
[buildPaginator(0)], [buildPaginator(0)],
[buildPaginator(1)], [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); const { container } = setUp(paginator);
expect(container.firstChild).toBeNull();
expect(container.firstChild).toBeEmptyDOMElement();
expect(container.firstChild).toHaveClass('pb-3');
}); });
it.each([ it.each([

View file

@ -64,7 +64,7 @@ describe('<ShortUrlForm />', () => {
}); });
it.each([ it.each([
['create' as Mode, 4], ['create' as Mode, 5],
['create-basic' as Mode, 0], ['create-basic' as Mode, 0],
])( ])(
'renders expected amount of cards based on server capabilities and mode', '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 { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar'; import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings';
import { DateRange } from '../../src/utils/helpers/dateIntervals'; import { DateRange } from '../../src/utils/helpers/dateIntervals';
import { formatDate } from '../../src/utils/helpers/date'; import { formatDate } from '../../src/utils/helpers/date';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
@ -30,6 +31,7 @@ describe('<ShortUrlsFilteringBar />', () => {
selectedServer={selectedServer ?? Mock.all<SelectedServer>()} selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
order={{}} order={{}}
handleOrderBy={handleOrderBy} handleOrderBy={handleOrderBy}
settings={Mock.of<Settings>({ visits: {} })}
/> />
</MemoryRouter>, </MemoryRouter>,
); );
@ -114,6 +116,28 @@ describe('<ShortUrlsFilteringBar />', () => {
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); 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 () => { it('handles order through dropdown', async () => {
const { user } = setUp(); const { user } = setUp();
const clickMenuItem = async (name: string | RegExp) => { const clickMenuItem = async (name: string | RegExp) => {

View file

@ -1,5 +1,4 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { FC } from 'react';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { MemoryRouter, useNavigate } from 'react-router-dom'; import { MemoryRouter, useNavigate } from 'react-router-dom';
import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList'; 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 { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
import { ReachableServer } from '../../src/servers/data'; import { ReachableServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings'; 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 { renderWithEvents } from '../__helpers__/setUpTest';
import { SemVer } from '../../src/utils/helpers/version';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@ -18,7 +18,7 @@ jest.mock('react-router-dom', () => ({
})); }));
describe('<ShortUrlsList />', () => { 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 ShortUrlsFilteringBar = () => <span>ShortUrlsFilteringBar</span>;
const listShortUrlsMock = jest.fn(); const listShortUrlsMock = jest.fn();
const navigate = jest.fn(); const navigate = jest.fn();
@ -36,14 +36,14 @@ describe('<ShortUrlsList />', () => {
}, },
}); });
const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar); const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar);
const setUp = (defaultOrdering: ShortUrlsOrder = {}) => renderWithEvents( const setUp = (settings: Partial<Settings> = {}, version: SemVer = '3.0.0') => renderWithEvents(
<MemoryRouter> <MemoryRouter>
<ShortUrlsList <ShortUrlsList
{...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })} {...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })}
listShortUrls={listShortUrlsMock} listShortUrls={listShortUrlsMock}
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
selectedServer={Mock.of<ReachableServer>({ id: '1' })} selectedServer={Mock.of<ReachableServer>({ id: '1', version })}
settings={Mock.of<Settings>({ shortUrlsList: { defaultOrdering } })} settings={Mock.of<Settings>(settings)}
/> />
</MemoryRouter>, </MemoryRouter>,
); );
@ -83,11 +83,39 @@ describe('<ShortUrlsList />', () => {
it.each([ it.each([
[Mock.of<ShortUrlsOrder>({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'], [Mock.of<ShortUrlsOrder>({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'],
[Mock.of<ShortUrlsOrder>({ field: 'title', dir: 'DESC' }), 'title', 'DESC'], [Mock.of<ShortUrlsOrder>({ field: 'title', dir: 'DESC' }), 'title', 'DESC'],
[Mock.of<ShortUrlsOrder>(), undefined, undefined], [Mock.all<ShortUrlsOrder>(), undefined, undefined],
])('has expected initial ordering based on settings', (initialOrderBy, field, dir) => { ])('has expected initial ordering based on settings', (defaultOrdering, field, dir) => {
setUp(initialOrderBy); setUp({ shortUrlsList: { defaultOrdering } });
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({
orderBy: { field, dir }, 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 saveImage = jest.fn().mockReturnValue(Promise.resolve());
const QrCodeModal = createQrCodeModal(Mock.of<ImageDownloader>({ saveImage })); const QrCodeModal = createQrCodeModal(Mock.of<ImageDownloader>({ saveImage }));
const shortUrl = 'https://doma.in/abc123'; const shortUrl = 'https://doma.in/abc123';
const setUp = (version: SemVer = '2.6.0') => renderWithEvents( const setUp = (version: SemVer = '2.8.0') => renderWithEvents(
<QrCodeModal <QrCodeModal
isOpen isOpen
shortUrl={Mock.of<ShortUrl>({ shortUrl })} shortUrl={Mock.of<ShortUrl>({ shortUrl })}
@ -32,12 +32,10 @@ describe('<QrCodeModal />', () => {
}); });
it.each([ it.each([
['2.5.0' as SemVer, 0, '/qr-code?size=300&format=png'], [10, '/qr-code?size=300&format=png&errorCorrection=L&margin=10'],
['2.6.0' as SemVer, 0, '/qr-code?size=300&format=png'], [0, '/qr-code?size=300&format=png&errorCorrection=L'],
['2.6.0' as SemVer, 10, '/qr-code?size=300&format=png&margin=10'], ])('displays an image with the QR code of the URL', async (margin, expectedUrl) => {
['2.8.0' as SemVer, 0, '/qr-code?size=300&format=png&errorCorrection=L'], const { container } = setUp();
])('displays an image with the QR code of the URL', async (version, margin, expectedUrl) => {
const { container } = setUp(version);
const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1); const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1);
if (marginControl) { if (marginControl) {
@ -69,16 +67,13 @@ describe('<QrCodeModal />', () => {
modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`); modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`);
}); });
it.each([ it('shows expected components based on server version', () => {
['2.6.0' as SemVer, 1, 'col-md-4'], const { container } = setUp();
['2.8.0' as SemVer, 2, 'col-md-6'],
])('shows expected components based on server version', (version, expectedAmountOfDropdowns, expectedRangeClass) => {
const { container } = setUp(version);
const dropdowns = screen.getAllByRole('button'); const dropdowns = screen.getAllByRole('button');
const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0); const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0);
expect(dropdowns).toHaveLength(expectedAmountOfDropdowns + 1); // Add one because of the close button expect(dropdowns).toHaveLength(2 + 1); // Add one because of the close button
expect(firstCol).toHaveClass(expectedRangeClass); expect(firstCol).toHaveClass('col-md-4');
}); });
it('saves the QR code image when clicking the Download button', async () => { 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 { Mock } from 'ts-mockery';
import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount'; import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
describe('<ShortUrlVisitsCount />', () => { describe('<ShortUrlVisitsCount />', () => {
const setUp = (visitsCount: number, shortUrl: ShortUrl) => render( const setUp = (visitsCount: number, shortUrl: ShortUrl) => ({
user: userEvent.setup(),
...render(
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />, <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 visitsCount = 45;
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta })); const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
@ -23,6 +27,30 @@ describe('<ShortUrlVisitsCount />', () => {
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta })); const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
expect(container.firstChild).toHaveTextContent(`/ ${maxVisits}`); 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 { screen } from '@testing-library/react';
import { last } from 'ramda';
import { Mock } from 'ts-mockery'; 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 { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow';
import { TimeoutToggle } from '../../../src/utils/helpers/hooks'; 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 { 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 { renderWithEvents } from '../../__helpers__/setUpTest';
import { OptionalString } from '../../../src/utils/utils'; import { OptionalString } from '../../../src/utils/utils';
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; 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 />', () => { describe('<ShortUrlsRow />', () => {
const timeoutToggle = jest.fn(() => true); const timeoutToggle = jest.fn(() => true);
const useTimeoutToggle = jest.fn(() => [false, timeoutToggle]) as TimeoutToggle; 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')), dateCreated: formatISO(parseDate('2018-05-23 18:30:41', 'yyyy-MM-dd HH:mm:ss')),
tags: [], tags: [],
visitsCount: 45, visitsCount: 45,
visitsSummary: {
total: 45,
nonBots: 40,
bots: 5,
},
domain: null, domain: null,
meta: { meta: {
validSince: null, validSince: null,
@ -29,20 +49,31 @@ describe('<ShortUrlsRow />', () => {
}, },
}; };
const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle); const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle);
const setUp = (title?: OptionalString, tags: string[] = []) => renderWithEvents(
const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => {
(useLocation as any).mockReturnValue({ search });
return renderWithEvents(
<MemoryRouter>
<table> <table>
<tbody> <tbody>
<ShortUrlsRow selectedServer={server} shortUrl={{ ...shortUrl, title, tags }} onTagClick={() => null} /> <ShortUrlsRow
selectedServer={server}
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
onTagClick={() => null}
settings={Mock.of<Settings>(settings)}
/>
</tbody> </tbody>
</table>, </table>
</MemoryRouter>,
); );
};
it.each([ it.each([
[null, 6], [null, 7],
[undefined, 6], [undefined, 7],
['The title', 7], ['The title', 8],
])('renders expected amount of columns', (title, expectedAmount) => { ])('renders expected amount of columns', (title, expectedAmount) => {
setUp(title); setUp({ title });
expect(screen.getAllByRole('cell')).toHaveLength(expectedAmount); expect(screen.getAllByRole('cell')).toHaveLength(expectedAmount);
}); });
@ -67,7 +98,7 @@ describe('<ShortUrlsRow />', () => {
['My super cool title', 'My super cool title'], ['My super cool title', 'My super cool title'],
[undefined, shortUrl.longUrl], [undefined, shortUrl.longUrl],
])('renders title when short URL has it', (title, expectedContent) => { ])('renders title when short URL has it', (title, expectedContent) => {
setUp(title); setUp({ title });
const titleSharedCol = screen.getAllByRole('cell')[2]; const titleSharedCol = screen.getAllByRole('cell')[2];
@ -79,22 +110,56 @@ describe('<ShortUrlsRow />', () => {
[[], ['No tags']], [[], ['No tags']],
[['nodejs', 'reactjs'], ['nodejs', 'reactjs']], [['nodejs', 'reactjs'], ['nodejs', 'reactjs']],
])('renders list of tags in fourth row', (tags, expectedContents) => { ])('renders list of tags in fourth row', (tags, expectedContents) => {
setUp(undefined, tags); setUp({ tags });
const cell = screen.getAllByRole('cell')[3]; const cell = screen.getAllByRole('cell')[3];
expectedContents.forEach((content) => expect(cell).toHaveTextContent(content)); expectedContents.forEach((content) => expect(cell).toHaveTextContent(content));
}); });
it('renders visits count in fifth row', () => { it.each([
setUp(); [{}, '', shortUrl.visitsSummary?.total],
expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${shortUrl.visitsCount}`); [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 () => { it('updates state when copied to clipboard', async () => {
const { user } = setUp(); const { user } = setUp();
expect(timeoutToggle).not.toHaveBeenCalled(); 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); 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