mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 09:47:28 +03:00
commit
5db0326350
121 changed files with 5881 additions and 24714 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -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
|
||||||
|
|
3
.github/workflows/deploy-preview.yml
vendored
3
.github/workflows/deploy-preview.yml
vendored
|
@ -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
|
||||||
|
|
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
|
@ -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
|
||||||
|
|
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -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*
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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/)
|
||||||
|
|
|
@ -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',
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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
90
index.html
Normal 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
145
manifest.ts
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
export const manifest = {
|
||||||
|
short_name: 'Shlink',
|
||||||
|
name: 'Shlink',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
theme_color: '#4696e5',
|
||||||
|
background_color: '#4696e5',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: './icons/icon-16x16.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '16x16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-24x24.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '24x24',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-32x32.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '32x32',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-40x40.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '40x40',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-48x48.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '48x48',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-60x60.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '60x60',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-64x64.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '64x64',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-72x72.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '72x72',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-76x76.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '76x76',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-96x96.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '96x96',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-114x114.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '114x114',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-120x120.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '120x120',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-128x128.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '128x128',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-144x144.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '144x144',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-150x150.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '150x150',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-152x152.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '152x152',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-160x160.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '160x160',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-167x167.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '167x167',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-180x180.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '180x180',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-192x192.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '192x192',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-196x196.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '196x196',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-228x228.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '228x228',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-256x256.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '256x256',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-310x310.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '310x310',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-384x384.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '384x384',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-512x512.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '512x512',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: './icons/icon-1024x1024.png',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '1024x1024',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
27869
package-lock.json
generated
27869
package-lock.json
generated
File diff suppressed because it is too large
Load diff
83
package.json
83
package.json
|
@ -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%",
|
||||||
|
|
|
@ -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>
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -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);
|
||||||
|
|
7
shlink-web-client.d.ts
vendored
7
shlink-web-client.d.ts
vendored
|
@ -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'
|
||||||
|
|
|
@ -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> => {
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>}
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||||
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
|
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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‘s effect might depend on Shlink server‘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>
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,30 +188,26 @@ 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">
|
<ShortUrlFormCheckboxGroup
|
||||||
{showCrawlableControl && (
|
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
||||||
<ShortUrlFormCheckboxGroup
|
checked={shortUrlData.crawlable}
|
||||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
||||||
checked={shortUrlData.crawlable}
|
>
|
||||||
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
Make it crawlable
|
||||||
>
|
</ShortUrlFormCheckboxGroup>
|
||||||
Make it crawlable
|
{showForwardQueryControl && (
|
||||||
</ShortUrlFormCheckboxGroup>
|
<ShortUrlFormCheckboxGroup
|
||||||
)}
|
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||||
{showForwardQueryControl && (
|
checked={shortUrlData.forwardQuery}
|
||||||
<ShortUrlFormCheckboxGroup
|
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
>
|
||||||
checked={shortUrlData.forwardQuery}
|
Forward query params on redirect
|
||||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
</ShortUrlFormCheckboxGroup>
|
||||||
>
|
)}
|
||||||
Forward query params on redirect
|
</SimpleCard>
|
||||||
</ShortUrlFormCheckboxGroup>
|
</div>
|
||||||
)}
|
|
||||||
</SimpleCard>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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,11 +84,25 @@ 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">
|
||||||
<DateRangeSelector
|
<div className="d-md-flex">
|
||||||
defaultText="All short URLs"
|
<div className="flex-fill">
|
||||||
initialDateRange={datesToDateRange(startDate, endDate)}
|
<DateRangeSelector
|
||||||
onDatesChange={setDates}
|
defaultText="All short URLs"
|
||||||
/>
|
initialDateRange={datesToDateRange(startDate, endDate)}
|
||||||
|
onDatesChange={setDates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ShortUrlsFilterDropdown
|
||||||
|
className="ms-0 ms-md-2 mt-3 mt-md-0"
|
||||||
|
selected={{
|
||||||
|
excludeBots: excludeBots ?? settings.visits?.excludeBots,
|
||||||
|
excludeMaxVisitsReached,
|
||||||
|
excludePastValidUntil,
|
||||||
|
}}
|
||||||
|
onChange={toFirstPage}
|
||||||
|
supportsDisabledFiltering={supportsDisabledFiltering}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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} />
|
||||||
|
@ -90,3 +119,5 @@ export const ShortUrlsFilteringBar = (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsFilteringBarType = ReturnType<typeof ShortUrlsFilteringBar>;
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"> </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>;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
@ -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">
|
||||||
|
|
86
src/short-urls/helpers/ShortUrlStatus.tsx
Normal file
86
src/short-urls/helpers/ShortUrlStatus.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
46
src/short-urls/helpers/ShortUrlsFilterDropdown.tsx
Normal file
46
src/short-urls/helpers/ShortUrlsFilterDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
29
src/short-urls/helpers/Tags.tsx
Normal file
29
src/short-urls/helpers/Tags.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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}`;
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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,26 +66,21 @@ 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
|
||||||
: (
|
sortedTags={sortedTags}
|
||||||
<TagsTable
|
selectedServer={selectedServer}
|
||||||
sortedTags={sortedTags}
|
currentOrder={order}
|
||||||
selectedServer={selectedServer}
|
orderByColumn={orderByColumn}
|
||||||
currentOrder={order}
|
/>
|
||||||
orderByColumn={orderByColumn}
|
);
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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}
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}`}`;
|
||||||
|
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,18 +158,16 @@ 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 && (
|
<>
|
||||||
<>
|
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
|
||||||
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
|
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
|
||||||
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
|
Potentially a visit from a bot or crawler
|
||||||
Potentially a visit from a bot or crawler
|
</UncontrolledTooltip>
|
||||||
</UncontrolledTooltip>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
|
||||||
<DropdownItem header>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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 }
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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({});
|
||||||
|
|
||||||
|
|
|
@ -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...'],
|
||||||
|
|
|
@ -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 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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([
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
48
test/short-urls/helpers/ShortUrlStatus.test.tsx
Normal file
48
test/short-urls/helpers/ShortUrlStatus.test.tsx
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
|
@ -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) => ({
|
||||||
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />,
|
user: userEvent.setup(),
|
||||||
);
|
...render(
|
||||||
|
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
it.each([undefined, {}])('just returns visits when no maxVisits is provided', (meta) => {
|
it.each([undefined, {}])('just returns visits when no limits are provided', (meta) => {
|
||||||
const visitsCount = 45;
|
const 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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
21
test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx
Normal file
21
test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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(
|
|
||||||
<table>
|
const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => {
|
||||||
<tbody>
|
(useLocation as any).mockReturnValue({ search });
|
||||||
<ShortUrlsRow selectedServer={server} shortUrl={{ ...shortUrl, title, tags }} onTagClick={() => null} />
|
return renderWithEvents(
|
||||||
</tbody>
|
<MemoryRouter>
|
||||||
</table>,
|
<table>
|
||||||
);
|
<tbody>
|
||||||
|
<ShortUrlsRow
|
||||||
|
selectedServer={server}
|
||||||
|
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
|
||||||
|
onTagClick={() => null}
|
||||||
|
settings={Mock.of<Settings>(settings)}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
it.each([
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
22
test/short-urls/helpers/Tags.test.tsx
Normal file
22
test/short-urls/helpers/Tags.test.tsx
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
145
test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap
Normal file
145
test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap
Normal 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
Loading…
Reference in a new issue