mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-25 03:06:36 +00:00
Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db0326350 | ||
|
|
7f6c678eaa | ||
|
|
37ac6cebc1 | ||
|
|
27099aa7fb | ||
|
|
91f4d09608 | ||
|
|
d34b9b1233 | ||
|
|
2badd2b743 | ||
|
|
4517f38680 | ||
|
|
84f9727836 | ||
|
|
85452cde23 | ||
|
|
9b19113262 | ||
|
|
e1bb091363 | ||
|
|
732d664715 | ||
|
|
33498ce903 | ||
|
|
c25b74de84 | ||
|
|
1c39e3402b | ||
|
|
a3bd10bc82 | ||
|
|
d6d237fc52 | ||
|
|
9b7a169110 | ||
|
|
2b17a24206 | ||
|
|
a0d9bd6f09 | ||
|
|
cd33abd92d | ||
|
|
c83563c0ea | ||
|
|
79515ac960 | ||
|
|
8e8a5f3fd6 | ||
|
|
51283cc130 | ||
|
|
4023c077b3 | ||
|
|
f3cf21ba08 | ||
|
|
bec7b59abf | ||
|
|
3e0abe329f | ||
|
|
822fe3db9e | ||
|
|
408ec82a10 | ||
|
|
ced3fa00ef | ||
|
|
595b3c0450 | ||
|
|
1d1c8153e7 | ||
|
|
52f556eb2e | ||
|
|
35fcd20123 | ||
|
|
e518b94fba | ||
|
|
05553ba18a | ||
|
|
4a9e05cf17 | ||
|
|
60fc351344 | ||
|
|
815e06809a | ||
|
|
bfcdf703e8 | ||
|
|
ddb2c1e641 | ||
|
|
e790360de9 | ||
|
|
b00f6fadf8 | ||
|
|
1d6f4bf5db | ||
|
|
80cea91339 | ||
|
|
5942cd6fcf | ||
|
|
2859ba6cd2 | ||
|
|
b9285fd600 | ||
|
|
901df2b90d | ||
|
|
662573d940 | ||
|
|
a8dcd3cac7 | ||
|
|
0ac16a1626 | ||
|
|
5162fa2a13 | ||
|
|
73a3d1d50f | ||
|
|
afc272c4d9 | ||
|
|
f8bcaed3ad | ||
|
|
d1a1b7426e | ||
|
|
99485cc6d8 | ||
|
|
187fee46f4 | ||
|
|
90837546ab | ||
|
|
f4cf4850a3 | ||
|
|
4ef1e491bc | ||
|
|
170f45d46b | ||
|
|
37caa1ad19 | ||
|
|
0312a0911c | ||
|
|
cc88f7678c | ||
|
|
653b470fec | ||
|
|
2603f2f987 | ||
|
|
b106b3cd0a | ||
|
|
b2b6b3af18 | ||
|
|
f911f78c95 | ||
|
|
a7560443f3 | ||
|
|
ab757b2f67 | ||
|
|
261cc68624 | ||
|
|
dc2db3a463 | ||
|
|
ae625e4c8a | ||
|
|
6f5c5b122f | ||
|
|
5d712d7d78 | ||
|
|
1654784471 | ||
|
|
7d83e434e6 | ||
|
|
97a54d44c7 | ||
|
|
6694ca6918 | ||
|
|
8e61e94fba | ||
|
|
69b1c8039e | ||
|
|
5bd89efc09 | ||
|
|
e5185f2099 | ||
|
|
d2ebc880a0 | ||
|
|
165afa436d | ||
|
|
a3f5095dce | ||
|
|
7bda5769fb | ||
|
|
0bf859d485 | ||
|
|
b79dced185 | ||
|
|
e368e618f3 | ||
|
|
bc4c69f207 | ||
|
|
32f29a84f7 | ||
|
|
f0a6420ba9 | ||
|
|
cf91637848 | ||
|
|
9bdf55374c | ||
|
|
d21758c410 | ||
|
|
bc2c945fee | ||
|
|
db2853880d | ||
|
|
cb76c89a08 | ||
|
|
059fa37ca7 | ||
|
|
54cc99448b | ||
|
|
a5dd96805d | ||
|
|
1e155af948 | ||
|
|
dd9ee044eb | ||
|
|
bd8ea17c84 | ||
|
|
0236f5132d | ||
|
|
b8adf5f274 | ||
|
|
c4bce5ec0a | ||
|
|
5bfe7dd128 | ||
|
|
9b3bdebb28 | ||
|
|
7575387236 | ||
|
|
984a99b24e | ||
|
|
a0767417b3 | ||
|
|
790c69ba80 | ||
|
|
f7ba974d97 | ||
|
|
1eab5af5c7 | ||
|
|
3df0bf79f8 | ||
|
|
34aa156d5f | ||
|
|
a88ebc26a9 | ||
|
|
d800062159 | ||
|
|
e5afe4f767 | ||
|
|
ee7a091586 | ||
|
|
7add83f985 | ||
|
|
16bee43f12 | ||
|
|
ba48104c5c | ||
|
|
cc620ddf79 | ||
|
|
6103f6a89b | ||
|
|
4b2c3d2db7 | ||
|
|
dac69daf03 | ||
|
|
3e474a3f2d | ||
|
|
f81999a4fe | ||
|
|
fd80fd65c9 | ||
|
|
ab7c52d049 | ||
|
|
a3cc3d5fc2 | ||
|
|
a6ed0c811d | ||
|
|
8e6b9c5afb | ||
|
|
b9efdd69f1 | ||
|
|
3b96b89492 | ||
|
|
32f7374d92 | ||
|
|
c6eec8b266 | ||
|
|
634ae94542 | ||
|
|
5095a2c59e | ||
|
|
002d2ba8e6 | ||
|
|
d44fe945d8 | ||
|
|
6221f9ed05 | ||
|
|
2e0e24d87b | ||
|
|
a1d869900b | ||
|
|
d90f6c2019 | ||
|
|
ed4c03f154 | ||
|
|
7bfccafca8 | ||
|
|
ae49090bad | ||
|
|
979c16eb9c | ||
|
|
fe85291772 | ||
|
|
893c5ace6f | ||
|
|
89423737e8 | ||
|
|
f9bfb742da | ||
|
|
b7622b2b38 | ||
|
|
8cfb4cf1e1 | ||
|
|
b9e02cf344 | ||
|
|
033df3c3d6 | ||
|
|
692eaf7dc9 | ||
|
|
22b3794154 | ||
|
|
dbb08a6ce0 | ||
|
|
0571a4a88f | ||
|
|
648744f440 | ||
|
|
f8fc1245ca | ||
|
|
5ecc791b38 | ||
|
|
2183b09ffe | ||
|
|
085ab521c3 | ||
|
|
61b274bab9 | ||
|
|
4ca31fc162 | ||
|
|
ae1d39bede | ||
|
|
f803941fe4 | ||
|
|
f93bb88d35 | ||
|
|
ea199dbf8f | ||
|
|
526d7195bc | ||
|
|
cf4143e4e2 | ||
|
|
0f552ae6f4 | ||
|
|
830071278e | ||
|
|
d468fb1efe | ||
|
|
2a268de2cb | ||
|
|
77cbb8ebc4 | ||
|
|
bf84e4a2ed | ||
|
|
a316366ae9 | ||
|
|
50823003b4 | ||
|
|
7c61033bdf | ||
|
|
d588d8d9ef | ||
|
|
cd90d3e581 | ||
|
|
54407af980 | ||
|
|
a31cdcc9f0 | ||
|
|
10d4419387 | ||
|
|
6f67f7bbf0 | ||
|
|
90ef41b419 | ||
|
|
62ab86aefa | ||
|
|
1dd26fb76f | ||
|
|
4a95724425 | ||
|
|
f209fa2d58 | ||
|
|
85e2aab4df | ||
|
|
26c3ea19f4 | ||
|
|
a1e2cd7274 | ||
|
|
6363822ffd | ||
|
|
34f4411aa1 | ||
|
|
b6d08e2203 | ||
|
|
4fa6ae493d | ||
|
|
79645099ba | ||
|
|
18d478e16e | ||
|
|
da97b76563 | ||
|
|
d25dbd5ae6 | ||
|
|
88e8f3363b | ||
|
|
24483ec330 | ||
|
|
15a9fba091 | ||
|
|
73e2485e09 | ||
|
|
0d94879e49 | ||
|
|
6df12ce194 | ||
|
|
c3b60367f3 | ||
|
|
10d3deff37 | ||
|
|
3cb79c167e | ||
|
|
57a17d7e92 | ||
|
|
894934fd08 | ||
|
|
5a8aae3614 | ||
|
|
3dde1a5b05 | ||
|
|
e6c79c19c2 | ||
|
|
d64abeecdc | ||
|
|
da6d45a72c | ||
|
|
497a735d80 | ||
|
|
89f031b338 | ||
|
|
47630dbcd2 | ||
|
|
0d57684565 | ||
|
|
5880767ac3 | ||
|
|
14c4c29af3 | ||
|
|
9f5614446e | ||
|
|
d755e8ffc4 | ||
|
|
a7a968ab6e | ||
|
|
f5757c6081 | ||
|
|
29fa4fa34d | ||
|
|
d2de9fb669 | ||
|
|
e124cd2490 | ||
|
|
e76c9041b5 | ||
|
|
755ae23fdb |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
ci:
|
||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: 18.12
|
||||
with-mutation-tests: true
|
||||
publish-coverage: true
|
||||
force-install: true
|
||||
|
||||
3
.github/workflows/deploy-preview.yml
vendored
3
.github/workflows/deploy-preview.yml
vendored
@@ -16,12 +16,11 @@ jobs:
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: 18.12
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci --force && \
|
||||
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||
rm src/service-worker.ts && \
|
||||
npm run build
|
||||
- name: Deploy preview
|
||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: 18.12
|
||||
- name: Generate release assets
|
||||
run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||
- name: Publish release with assets
|
||||
|
||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -4,6 +4,92 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [3.9.0] - 2022-12-31
|
||||
### Added
|
||||
* [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc.
|
||||
* [#764](https://github.com/shlinkio/shlink-web-client/issues/764) Added support to exclude visits from visits on short URLs list when consuming Shlink 3.4.0.
|
||||
|
||||
This feature also comes with a new setting to disable visits from bots by default, both on short URLs lists and visits sections.
|
||||
|
||||
* [#760](https://github.com/shlinkio/shlink-web-client/issues/760) Added support to exclude short URLs which have reached the maximum amount of visits, or are valid until a date in the past.
|
||||
|
||||
### Changed
|
||||
* [#753](https://github.com/shlinkio/shlink-web-client/issues/753) Migrated from react-scripts/webpack to vite.
|
||||
* [#770](https://github.com/shlinkio/shlink-web-client/issues/770) Updated to latest dependencies.
|
||||
* [#741](https://github.com/shlinkio/shlink-web-client/issues/741) Improved `visitsAsyncThunk`, making it wrap pending/fulfilled/rejected actions, as well as custom ones, in a type-safe way.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#736](https://github.com/shlinkio/shlink-web-client/issues/736) Removed cards mode in tags. Only table mode is supported now.
|
||||
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
|
||||
|
||||
### Fixed
|
||||
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
|
||||
|
||||
|
||||
## [3.8.2] - 2022-12-17
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#766](https://github.com/shlinkio/shlink-web-client/issues/766) Fixed visits query being lost when switching between sub-sections.
|
||||
* [#765](https://github.com/shlinkio/shlink-web-client/issues/765) Added missing `"Content-Type": "application/json"` to requests with payload, making older Shlink versions fail.
|
||||
|
||||
|
||||
## [3.8.1] - 2022-12-06
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#756](https://github.com/shlinkio/shlink-web-client/issues/756) Fixed all visits interval not working unless switching to a different interval first.
|
||||
* [#757](https://github.com/shlinkio/shlink-web-client/issues/757) Fixed visits fallback interval not working until the visits view has been loaded at least twice.
|
||||
|
||||
|
||||
## [3.8.0] - 2022-12-03
|
||||
### Added
|
||||
* [#708](https://github.com/shlinkio/shlink-web-client/issues/708) Added support for API v3.
|
||||
* [#717](https://github.com/shlinkio/shlink-web-client/issues/717) Allowed to select time in 10 minute intervals when configuring "enabled since" and "enabled until" on short URLs.
|
||||
* [#748](https://github.com/shlinkio/shlink-web-client/issues/748) Improved visits section to add filters to the query string, allowing to navigate to a specific state or bookmarking filters.
|
||||
|
||||
### Changed
|
||||
* [#713](https://github.com/shlinkio/shlink-web-client/issues/713) Updated dependencies.
|
||||
* [#620](https://github.com/shlinkio/shlink-web-client/issues/620) Migrated all reducers to redux toolkit.
|
||||
* [#721](https://github.com/shlinkio/shlink-web-client/issues/721) Migrated from axios to fetch.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#590](https://github.com/shlinkio/shlink-web-client/issues/590) Fixed position of the datepicker triangle.
|
||||
* [#729](https://github.com/shlinkio/shlink-web-client/issues/729) Fixed wrong stats displayed in tags after renaming.
|
||||
* [#737](https://github.com/shlinkio/shlink-web-client/issues/737) Fixed incorrect contrast in warning messages when using dark theme.
|
||||
* [#726](https://github.com/shlinkio/shlink-web-client/issues/726) Fixed delete server and delete short URL modals getting removed from the DOM before finishing close transition.
|
||||
* [#749](https://github.com/shlinkio/shlink-web-client/issues/749) Fixed broken short URLs table when some short URL has a too long custom slug.
|
||||
|
||||
|
||||
## [3.7.3] - 2022-09-13
|
||||
### Added
|
||||
* [#703](https://github.com/shlinkio/shlink-web-client/issues/703) Added support to publish docker image in GHCR.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM node:16.15-alpine as node
|
||||
FROM node:18.12-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && npm ci --force && NODE_ENV=production npm run build
|
||||
RUN cd /shlink-web-client && npm ci --force && npm run build
|
||||
|
||||
FROM nginx:1.21-alpine
|
||||
FROM nginx:1.23-alpine
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://github.com/shlinkio/shlink-web-client/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||
[](https://twitter.com/shlinkio)
|
||||
[](https://fosstodon.org/@shlinkio)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'react-app',
|
||||
{
|
||||
runtime: 'automatic',
|
||||
typescript: true,
|
||||
},
|
||||
],
|
||||
['@babel/preset-env', {
|
||||
targets: { esmodules: true }
|
||||
}],
|
||||
['@babel/preset-react', { runtime: 'automatic' }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import 'jest-canvas-mock';
|
||||
import 'chart.js/auto';
|
||||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
import { setAutoFreeze } from 'immer';
|
||||
|
||||
(global as any).ResizeObserver = ResizeObserver;
|
||||
(global as any).scrollTo = () => {};
|
||||
(global as any).prompt = () => {};
|
||||
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
||||
|
||||
setAutoFreeze(false); // TODO Bypassing a bug on jest
|
||||
|
||||
@@ -3,8 +3,8 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:16.15-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
image: node:18.12-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install --force && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
ports:
|
||||
|
||||
90
index.html
Normal file
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>
|
||||
@@ -17,6 +17,7 @@ module.exports = {
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
||||
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/.stryker-tmp'],
|
||||
testEnvironment: 'jsdom',
|
||||
testEnvironmentOptions: {
|
||||
url: 'http://localhost',
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
31762
package-lock.json
generated
31762
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
124
package.json
124
package.json
@@ -12,8 +12,8 @@
|
||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
|
||||
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.mjs",
|
||||
"start": "vite serve --host=0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build && node scripts/replace-version.mjs",
|
||||
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
|
||||
"build:serve": "serve -p 5000 ./build",
|
||||
"test": "jest --env=jsdom --colors",
|
||||
@@ -24,87 +24,91 @@
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.17",
|
||||
"axios": "^0.26.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"bottlejs": "^2.0.0",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@fortawesome/fontawesome-free": "^6.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@json2csv/plainjs": "^6.1.2",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
"bootstrap": "^5.2.3",
|
||||
"bottlejs": "^2.0.1",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^3.7.1",
|
||||
"classnames": "^2.3.1",
|
||||
"compare-versions": "^4.1.3",
|
||||
"chart.js": "^4.1.1",
|
||||
"classnames": "^2.3.2",
|
||||
"compare-versions": "^5.0.3",
|
||||
"csvtojson": "^2.0.10",
|
||||
"date-fns": "^2.28.0",
|
||||
"event-source-polyfill": "^1.0.25",
|
||||
"json2csv": "^5.0.7",
|
||||
"leaflet": "^1.7.1",
|
||||
"qs": "^6.9.6",
|
||||
"date-fns": "^2.29.3",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"history": "^5.3.0",
|
||||
"leaflet": "^1.9.3",
|
||||
"qs": "^6.11.0",
|
||||
"ramda": "^0.27.2",
|
||||
"react": "^18.1.0",
|
||||
"react-chartjs-2": "^4.1.0",
|
||||
"react-colorful": "^5.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-datepicker": "^4.8.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-external-link": "^2.0.0",
|
||||
"react-leaflet": "^4.0.0",
|
||||
"react-redux": "^8.0.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-leaflet": "^4.2.0",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.6.1",
|
||||
"react-swipeable": "^7.0.0",
|
||||
"react-tag-autocomplete": "^6.3.0",
|
||||
"reactstrap": "^9.0.1",
|
||||
"reactstrap": "^9.1.5",
|
||||
"redux": "^4.2.0",
|
||||
"redux-localstorage-simple": "^2.4.1",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"stream": "^0.0.2",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"redux-thunk": "^2.4.2",
|
||||
"uuid": "^8.3.2",
|
||||
"workbox-core": "^6.5.1",
|
||||
"workbox-expiration": "^6.5.1",
|
||||
"workbox-precaching": "^6.5.1",
|
||||
"workbox-routing": "^6.5.1",
|
||||
"workbox-strategies": "^6.5.1"
|
||||
"workbox-core": "^6.5.4",
|
||||
"workbox-expiration": "^6.5.4",
|
||||
"workbox-precaching": "^6.5.4",
|
||||
"workbox-routing": "^6.5.4",
|
||||
"workbox-strategies": "^6.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
|
||||
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
|
||||
"@stryker-mutator/core": "^6.0.2",
|
||||
"@stryker-mutator/jest-runner": "^6.0.2",
|
||||
"@stryker-mutator/typescript-checker": "^6.0.2",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@stryker-mutator/core": "^6.3.1",
|
||||
"@stryker-mutator/jest-runner": "^6.3.1",
|
||||
"@stryker-mutator/typescript-checker": "^6.3.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/jest": "^29.2.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/leaflet": "^1.7.9",
|
||||
"@types/leaflet": "^1.9.0",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/ramda": "0.27.38",
|
||||
"@types/react": "^18.0.8",
|
||||
"@types/ramda": "^0.28.15",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||
"@types/react-datepicker": "^4.3.4",
|
||||
"@types/react-dom": "^18.0.3",
|
||||
"@types/react-tag-autocomplete": "^6.1.1",
|
||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||
"@types/react-datepicker": "^4.8.0",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/react-tag-autocomplete": "^6.3.0",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"adm-zip": "^0.5.9",
|
||||
"babel-jest": "^28.0.3",
|
||||
"chalk": "^5.0.1",
|
||||
"eslint": "^8.12.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"adm-zip": "^0.5.10",
|
||||
"babel-jest": "^29.3.1",
|
||||
"chalk": "^5.2.0",
|
||||
"eslint": "^8.30.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^28.0.3",
|
||||
"jest": "^29.3.1",
|
||||
"jest-canvas-mock": "^2.4.0",
|
||||
"jest-environment-jsdom": "^28.0.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"jest-environment-jsdom": "^29.3.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.49.9",
|
||||
"serve": "^13.0.2",
|
||||
"sass": "^1.57.1",
|
||||
"serve": "^14.1.2",
|
||||
"stryker-cli": "^1.0.2",
|
||||
"stylelint": "^14.8.2",
|
||||
"stylelint": "^14.16.0",
|
||||
"ts-mockery": "^1.2.0",
|
||||
"typescript": "^4.6.2",
|
||||
"webpack": "^5.70.0"
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.0.3",
|
||||
"vite-plugin-pwa": "^0.14.0"
|
||||
},
|
||||
"browserslist": [
|
||||
">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';
|
||||
|
||||
function replaceVersionPlaceholder(version) {
|
||||
const staticJsFilesPath = './build/static/js';
|
||||
const staticJsFilesPath = './build/assets';
|
||||
const versionPlaceholder = '%_VERSION_%';
|
||||
|
||||
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
|
||||
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
|
||||
const isMainFile = (file) => file.startsWith('index-') && file.endsWith('.js');
|
||||
const [mainJsFile] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
|
||||
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const replaced = fileContent.replace(versionPlaceholder, version);
|
||||
|
||||
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 class EventSourcePolyfill {
|
||||
public onmessage?: ({ data }: { data: string }) => void;
|
||||
@@ -7,4 +8,10 @@ declare module 'event-source-polyfill' {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@json2csv/plainjs' {
|
||||
export class Parser {
|
||||
parse: <T>(data: T[]) => string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.png'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ProblemDetailsError } from './types';
|
||||
import { isInvalidArgumentError } from './utils';
|
||||
import { ProblemDetailsError } from './types/errors';
|
||||
|
||||
export interface ShlinkApiErrorProps {
|
||||
errorData?: ProblemDetailsError;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import {
|
||||
@@ -18,111 +17,127 @@ import {
|
||||
ShlinkShortUrlsListParams,
|
||||
ShlinkShortUrlsListNormalizedParams,
|
||||
} from '../types';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
import { orderToString } from '../../utils/helpers/ordering';
|
||||
import { isRegularNotFound, parseApiError } from '../utils';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
import { HttpClient } from '../../common/services/HttpClient';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : '');
|
||||
const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`;
|
||||
const rejectNilProps = reject(isNil);
|
||||
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||
const { orderBy = {}, ...rest } = params;
|
||||
|
||||
return { ...rest, orderBy: orderToString(orderBy) };
|
||||
};
|
||||
const normalizeListParams = (
|
||||
{ orderBy = {}, excludeMaxVisitsReached, excludePastValidUntil, ...rest }: ShlinkShortUrlsListParams,
|
||||
): ShlinkShortUrlsListNormalizedParams => ({
|
||||
...rest,
|
||||
excludeMaxVisitsReached: excludeMaxVisitsReached === true ? 'true' : undefined,
|
||||
excludePastValidUntil: excludePastValidUntil === true ? 'true' : undefined,
|
||||
orderBy: orderToString(orderBy),
|
||||
});
|
||||
|
||||
export class ShlinkApiClient {
|
||||
private apiVersion: 2 | 3;
|
||||
|
||||
public constructor(
|
||||
private readonly axios: AxiosInstance,
|
||||
private readonly httpClient: HttpClient,
|
||||
private readonly baseUrl: string,
|
||||
private readonly apiKey: string,
|
||||
) {
|
||||
this.apiVersion = 3;
|
||||
}
|
||||
|
||||
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
|
||||
.then(({ data }) => data.shortUrls);
|
||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeListParams(params))
|
||||
.then(({ shortUrls }) => shortUrls);
|
||||
|
||||
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
||||
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any);
|
||||
|
||||
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions)
|
||||
.then((resp) => resp.data);
|
||||
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions);
|
||||
};
|
||||
|
||||
public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits> =>
|
||||
this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query)
|
||||
.then(({ data }) => data.visits);
|
||||
.then(({ visits }) => visits);
|
||||
|
||||
public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
||||
.then(({ data }) => data.visits);
|
||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query).then(({ visits }) => visits);
|
||||
|
||||
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query)
|
||||
.then(({ data }) => data.visits);
|
||||
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query).then(({ visits }) => visits);
|
||||
|
||||
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
||||
.then(({ data }) => data.visits);
|
||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query).then(({ visits }) => visits);
|
||||
|
||||
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query)
|
||||
.then(({ data }) => data.visits);
|
||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query).then(({ visits }) => visits);
|
||||
|
||||
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
||||
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
||||
.then(({ data }) => data.visits);
|
||||
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits').then(({ visits }) => visits);
|
||||
|
||||
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||
.then(({ data }) => data);
|
||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain });
|
||||
|
||||
public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> =>
|
||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||
.then(() => {});
|
||||
this.performEmptyRequest(`/short-urls/${shortCode}`, 'DELETE', { domain });
|
||||
|
||||
public readonly updateShortUrl = async (
|
||||
shortCode: string,
|
||||
domain: OptionalString,
|
||||
edit: ShlinkShortUrlData,
|
||||
): Promise<ShortUrl> =>
|
||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
|
||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit);
|
||||
|
||||
public readonly listTags = async (): Promise<ShlinkTags> =>
|
||||
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
||||
.then((resp) => resp.data.tags)
|
||||
.then(({ tags }) => tags)
|
||||
.then(({ data, stats }) => ({ tags: data, stats }));
|
||||
|
||||
public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
|
||||
this.performRequest('/tags', 'DELETE', { tags })
|
||||
.then(() => ({ tags }));
|
||||
this.performEmptyRequest('/tags', 'DELETE', { tags }).then(() => ({ tags }));
|
||||
|
||||
public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> =>
|
||||
this.performRequest('/tags', 'PUT', {}, { oldName, newName })
|
||||
.then(() => ({ oldName, newName }));
|
||||
this.performEmptyRequest('/tags', 'PUT', {}, { oldName, newName }).then(() => ({ oldName, newName }));
|
||||
|
||||
public readonly health = async (): Promise<ShlinkHealth> =>
|
||||
this.performRequest<ShlinkHealth>('/health', 'GET')
|
||||
.then((resp) => resp.data);
|
||||
public readonly health = async (): Promise<ShlinkHealth> => this.performRequest<ShlinkHealth>('/health', 'GET');
|
||||
|
||||
public readonly mercureInfo = async (): Promise<ShlinkMercureInfo> =>
|
||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||
.then((resp) => resp.data);
|
||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET');
|
||||
|
||||
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains').then(({ domains }) => domains);
|
||||
|
||||
public readonly editDomainRedirects = async (
|
||||
domainRedirects: ShlinkEditDomainRedirects,
|
||||
): Promise<ShlinkDomainRedirects> =>
|
||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects);
|
||||
|
||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
|
||||
this.axios({
|
||||
private readonly performRequest = async <T>(url: string, method = 'GET', query = {}, body?: object): Promise<T> =>
|
||||
this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body)).catch(
|
||||
this.handleFetchError(() => this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body))),
|
||||
);
|
||||
|
||||
private readonly performEmptyRequest = async (url: string, method = 'GET', query = {}, body?: object): Promise<void> =>
|
||||
this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body)).catch(
|
||||
this.handleFetchError(() => this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body))),
|
||||
);
|
||||
|
||||
private readonly toFetchParams = (url: string, method: string, query = {}, body?: object): [string, RequestInit] => {
|
||||
const normalizedQuery = stringifyQuery(rejectNilProps(query));
|
||||
const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`;
|
||||
|
||||
return [`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, {
|
||||
method,
|
||||
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
|
||||
body: body && JSON.stringify(body),
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: stringifyQuery,
|
||||
});
|
||||
}];
|
||||
};
|
||||
|
||||
private readonly handleFetchError = (retryFetch: Function) => (e: unknown) => {
|
||||
if (!isRegularNotFound(parseApiError(e))) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to
|
||||
// v2 and retry
|
||||
this.apiVersion = 2;
|
||||
return retryFetch();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { prop } from 'ramda';
|
||||
import { hasServerData, SelectedServer, ServerWithId } from '../../servers/data';
|
||||
import { hasServerData, ServerWithId } from '../../servers/data';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClient } from './ShlinkApiClient';
|
||||
import { HttpClient } from '../../common/services/HttpClient';
|
||||
|
||||
const apiClients: Record<string, ShlinkApiClient> = {};
|
||||
|
||||
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
||||
typeof getStateOrSelectedServer === 'function';
|
||||
const getSelectedServerFromState = (getState: GetState): SelectedServer => prop('selectedServer', getState());
|
||||
|
||||
export type ShlinkApiClientBuilder = (getStateOrSelectedServer: GetState | ServerWithId) => ShlinkApiClient;
|
||||
|
||||
export const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuilder => (
|
||||
getStateOrSelectedServer: GetState | ServerWithId,
|
||||
) => {
|
||||
const server = isGetState(getStateOrSelectedServer)
|
||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||
: getStateOrSelectedServer;
|
||||
|
||||
if (!hasServerData(server)) {
|
||||
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
||||
const { selectedServer } = getState();
|
||||
if (!hasServerData(selectedServer)) {
|
||||
throw new Error('There\'s no selected server or it is not found');
|
||||
}
|
||||
|
||||
const { url, apiKey } = server;
|
||||
return selectedServer;
|
||||
};
|
||||
|
||||
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
||||
const { url, apiKey } = isGetState(getStateOrSelectedServer)
|
||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||
: getStateOrSelectedServer;
|
||||
const clientKey = `${url}_${apiKey}`;
|
||||
|
||||
if (!apiClients[clientKey]) {
|
||||
apiClients[clientKey] = new ShlinkApiClient(axios, url, apiKey);
|
||||
apiClients[clientKey] = new ShlinkApiClient(httpClient, url, apiKey);
|
||||
}
|
||||
|
||||
return apiClients[clientKey];
|
||||
};
|
||||
|
||||
export type ShlinkApiClientBuilder = ReturnType<typeof buildShlinkApiClient>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import Bottle from 'bottlejs';
|
||||
import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
|
||||
|
||||
const provideServices = (bottle: Bottle) => {
|
||||
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
|
||||
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Action } from 'redux';
|
||||
import { ProblemDetailsError } from './index';
|
||||
|
||||
export interface ApiErrorAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
54
src/api/types/errors.ts
Normal file
54
src/api/types/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export enum ErrorTypeV2 {
|
||||
INVALID_ARGUMENT = 'INVALID_ARGUMENT',
|
||||
INVALID_SHORT_URL_DELETION = 'INVALID_SHORT_URL_DELETION',
|
||||
DOMAIN_NOT_FOUND = 'DOMAIN_NOT_FOUND',
|
||||
FORBIDDEN_OPERATION = 'FORBIDDEN_OPERATION',
|
||||
INVALID_URL = 'INVALID_URL',
|
||||
INVALID_SLUG = 'INVALID_SLUG',
|
||||
INVALID_SHORTCODE = 'INVALID_SHORTCODE',
|
||||
TAG_CONFLICT = 'TAG_CONFLICT',
|
||||
TAG_NOT_FOUND = 'TAG_NOT_FOUND',
|
||||
MERCURE_NOT_CONFIGURED = 'MERCURE_NOT_CONFIGURED',
|
||||
INVALID_AUTHORIZATION = 'INVALID_AUTHORIZATION',
|
||||
INVALID_API_KEY = 'INVALID_API_KEY',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
}
|
||||
|
||||
export enum ErrorTypeV3 {
|
||||
INVALID_ARGUMENT = 'https://shlink.io/api/error/invalid-data',
|
||||
INVALID_SHORT_URL_DELETION = 'https://shlink.io/api/error/invalid-short-url-deletion',
|
||||
DOMAIN_NOT_FOUND = 'https://shlink.io/api/error/domain-not-found',
|
||||
FORBIDDEN_OPERATION = 'https://shlink.io/api/error/forbidden-tag-operation',
|
||||
INVALID_URL = 'https://shlink.io/api/error/invalid-url',
|
||||
INVALID_SLUG = 'https://shlink.io/api/error/non-unique-slug',
|
||||
INVALID_SHORTCODE = 'https://shlink.io/api/error/short-url-not-found',
|
||||
TAG_CONFLICT = 'https://shlink.io/api/error/tag-conflict',
|
||||
TAG_NOT_FOUND = 'https://shlink.io/api/error/tag-not-found',
|
||||
MERCURE_NOT_CONFIGURED = 'https://shlink.io/api/error/mercure-not-configured',
|
||||
INVALID_AUTHORIZATION = 'https://shlink.io/api/error/missing-authentication',
|
||||
INVALID_API_KEY = 'https://shlink.io/api/error/invalid-api-key',
|
||||
NOT_FOUND = 'https://shlink.io/api/error/not-found',
|
||||
}
|
||||
|
||||
export interface ProblemDetailsError {
|
||||
type: string;
|
||||
detail: string;
|
||||
title: string;
|
||||
status: number;
|
||||
[extraProps: string]: any;
|
||||
}
|
||||
|
||||
export interface InvalidArgumentError extends ProblemDetailsError {
|
||||
type: ErrorTypeV2.INVALID_ARGUMENT | ErrorTypeV3.INVALID_ARGUMENT;
|
||||
invalidElements: string[];
|
||||
}
|
||||
|
||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||
type: 'INVALID_SHORTCODE_DELETION' | ErrorTypeV2.INVALID_SHORT_URL_DELETION | ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface RegularNotFound extends ProblemDetailsError {
|
||||
type: ErrorTypeV2.NOT_FOUND | ErrorTypeV3.NOT_FOUND;
|
||||
status: 404;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Visit } from '../../visits/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
|
||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||
import { Order } from '../../utils/helpers/ordering';
|
||||
|
||||
export interface ShlinkShortUrlsResponse {
|
||||
data: ShortUrl[];
|
||||
@@ -88,35 +89,26 @@ export interface ShlinkDomainsResponse {
|
||||
|
||||
export type TagsFilteringMode = 'all' | 'any';
|
||||
|
||||
type ShlinkShortUrlsOrderableFields = 'dateCreated' | 'shortCode' | 'longUrl' | 'title' | 'visits' | 'nonBotVisits';
|
||||
|
||||
export type ShlinkShortUrlsOrder = Order<ShlinkShortUrlsOrderableFields>;
|
||||
|
||||
export interface ShlinkShortUrlsListParams {
|
||||
page?: string;
|
||||
itemsPerPage?: number;
|
||||
tags?: string[];
|
||||
searchTerm?: string;
|
||||
tags?: string[];
|
||||
tagsMode?: TagsFilteringMode;
|
||||
orderBy?: ShlinkShortUrlsOrder;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: ShortUrlsOrder;
|
||||
tagsMode?: TagsFilteringMode;
|
||||
excludeMaxVisitsReached?: boolean;
|
||||
excludePastValidUntil?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||
export interface ShlinkShortUrlsListNormalizedParams extends
|
||||
Omit<ShlinkShortUrlsListParams, 'orderBy' | 'excludeMaxVisitsReached' | 'excludePastValidUntil'> {
|
||||
orderBy?: string;
|
||||
}
|
||||
|
||||
export interface ProblemDetailsError {
|
||||
type: string;
|
||||
detail: string;
|
||||
title: string;
|
||||
status: number;
|
||||
[extraProps: string]: any;
|
||||
}
|
||||
|
||||
export interface InvalidArgumentError extends ProblemDetailsError {
|
||||
type: 'INVALID_ARGUMENT';
|
||||
invalidElements: string[];
|
||||
}
|
||||
|
||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
|
||||
threshold: number;
|
||||
excludeMaxVisitsReached?: 'true';
|
||||
excludePastValidUntil?: 'true';
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError } from '../types';
|
||||
import {
|
||||
ErrorTypeV2,
|
||||
ErrorTypeV3,
|
||||
InvalidArgumentError,
|
||||
InvalidShortUrlDeletion,
|
||||
ProblemDetailsError,
|
||||
RegularNotFound,
|
||||
} from '../types/errors';
|
||||
|
||||
export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data;
|
||||
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
|
||||
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
|
||||
|
||||
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);
|
||||
|
||||
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
||||
error?.type === 'INVALID_ARGUMENT';
|
||||
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;
|
||||
|
||||
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
||||
error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION';
|
||||
error?.type === 'INVALID_SHORTCODE_DELETION'
|
||||
|| error?.type === ErrorTypeV2.INVALID_SHORT_URL_DELETION
|
||||
|| error?.type === ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
||||
|
||||
export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound =>
|
||||
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Action } from 'redux';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
||||
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
||||
const { actions, reducer } = createSlice({
|
||||
name: 'shlink/appUpdates',
|
||||
initialState: false,
|
||||
reducers: {
|
||||
appUpdateAvailable: () => true,
|
||||
resetAppUpdate: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
const initialState = false;
|
||||
export const { appUpdateAvailable, resetAppUpdate } = actions;
|
||||
|
||||
export default buildReducer<boolean, Action<string>>({
|
||||
[APP_UPDATE_AVAILABLE]: () => true,
|
||||
[RESET_APP_UPDATE]: () => false,
|
||||
}, initialState);
|
||||
|
||||
export const appUpdateAvailable = buildActionCreator(APP_UPDATE_AVAILABLE);
|
||||
|
||||
export const resetAppUpdate = buildActionCreator(RESET_APP_UPDATE);
|
||||
export const appUpdatesReducer = reducer;
|
||||
|
||||
@@ -12,7 +12,6 @@ import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
@@ -22,6 +21,7 @@ export interface AsideMenuProps {
|
||||
|
||||
interface AsideMenuItemProps extends NavLinkProps {
|
||||
to: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
@@ -40,7 +40,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
const hasId = isServerWithId(selectedServer);
|
||||
const serverId = hasId ? selectedServer.id : '';
|
||||
const { pathname } = useLocation();
|
||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
@@ -68,12 +67,10 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
{addManageDomainsLink && (
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
)}
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
|
||||
import { supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import { NotFound } from './NotFound';
|
||||
import { AsideMenuProps } from './AsideMenu';
|
||||
@@ -38,7 +38,6 @@ export const MenuLayout = (
|
||||
useEffect(() => hideSidebar(), [location]);
|
||||
useEffect(() => {
|
||||
showContent && sidebarPresent();
|
||||
|
||||
return () => sidebarNotPresent();
|
||||
}, []);
|
||||
|
||||
@@ -47,7 +46,6 @@ export const MenuLayout = (
|
||||
}
|
||||
|
||||
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
||||
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||
const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||
@@ -73,7 +71,7 @@ export const MenuLayout = (
|
||||
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
|
||||
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
||||
<Route path="/manage-tags" element={<TagsList />} />
|
||||
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
|
||||
<Route path="/manage-domains" element={<ManageDomains />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import { Action } from 'redux';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
|
||||
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
|
||||
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export interface Sidebar {
|
||||
sidebarPresent: boolean;
|
||||
}
|
||||
|
||||
type SidebarRenderedAction = Action<string>;
|
||||
type SidebarNotRenderedAction = Action<string>;
|
||||
|
||||
const initialState: Sidebar = {
|
||||
sidebarPresent: false,
|
||||
};
|
||||
|
||||
export default buildReducer<Sidebar, SidebarRenderedAction & SidebarNotRenderedAction>({
|
||||
[SIDEBAR_PRESENT]: () => ({ sidebarPresent: true }),
|
||||
[SIDEBAR_NOT_PRESENT]: () => ({ sidebarPresent: false }),
|
||||
}, initialState);
|
||||
const { actions, reducer } = createSlice({
|
||||
name: 'shlink/sidebar',
|
||||
initialState,
|
||||
reducers: {
|
||||
sidebarPresent: () => ({ sidebarPresent: true }),
|
||||
sidebarNotPresent: () => ({ sidebarPresent: false }),
|
||||
},
|
||||
});
|
||||
|
||||
export const sidebarPresent = buildActionCreator(SIDEBAR_PRESENT);
|
||||
export const { sidebarPresent, sidebarNotPresent } = actions;
|
||||
|
||||
export const sidebarNotPresent = buildActionCreator(SIDEBAR_NOT_PRESENT);
|
||||
export const sidebarReducer = reducer;
|
||||
|
||||
42
src/common/services/HttpClient.ts
Normal file
42
src/common/services/HttpClient.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Fetch } from '../../utils/types';
|
||||
|
||||
const applicationJsonHeader = { 'Content-Type': 'application/json' };
|
||||
const withJsonContentType = (options?: RequestInit): RequestInit | undefined => {
|
||||
if (!options?.body) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options ? {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...applicationJsonHeader,
|
||||
},
|
||||
} : {
|
||||
headers: applicationJsonHeader,
|
||||
};
|
||||
};
|
||||
|
||||
export class HttpClient {
|
||||
constructor(private readonly fetch: Fetch) {}
|
||||
|
||||
public readonly fetchJson = <T>(url: string, options?: RequestInit): Promise<T> =>
|
||||
this.fetch(url, withJsonContentType(options)).then(async (resp) => {
|
||||
const json = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
throw json;
|
||||
}
|
||||
|
||||
return json as T;
|
||||
});
|
||||
|
||||
public readonly fetchEmpty = (url: string, options?: RequestInit): Promise<void> =>
|
||||
this.fetch(url, withJsonContentType(options)).then(async (resp) => {
|
||||
if (!resp.ok) {
|
||||
throw await resp.json();
|
||||
}
|
||||
});
|
||||
|
||||
public readonly fetchBlob = (url: string): Promise<Blob> => this.fetch(url).then((resp) => resp.blob());
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { saveUrl } from '../../utils/helpers/files';
|
||||
import { HttpClient } from './HttpClient';
|
||||
|
||||
export class ImageDownloader {
|
||||
public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
|
||||
public constructor(private readonly httpClient: HttpClient, private readonly window: Window) {}
|
||||
|
||||
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
||||
const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
|
||||
const data = await this.httpClient.fetchBlob(imgUrl);
|
||||
const url = URL.createObjectURL(data);
|
||||
|
||||
saveUrl(this.window, url, filename);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from 'axios';
|
||||
import Bottle from 'bottlejs';
|
||||
import { ScrollToTop } from '../ScrollToTop';
|
||||
import { MainHeader } from '../MainHeader';
|
||||
@@ -12,14 +11,16 @@ import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServ
|
||||
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
|
||||
import { ImageDownloader } from './ImageDownloader';
|
||||
import { ReportExporter } from './ReportExporter';
|
||||
import { HttpClient } from './HttpClient';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Services
|
||||
bottle.constant('window', (global as any).window);
|
||||
bottle.constant('console', global.console);
|
||||
bottle.constant('axios', axios);
|
||||
bottle.constant('window', window);
|
||||
bottle.constant('console', console);
|
||||
bottle.constant('fetch', window.fetch.bind(window));
|
||||
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||
bottle.service('HttpClient', HttpClient, 'fetch');
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window');
|
||||
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||
|
||||
// Components
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import ReduxThunk from 'redux-thunk';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import { IContainer } from 'bottlejs';
|
||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||
import reducers from '../reducers';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import reducer from '../reducers';
|
||||
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||
import { ShlinkState } from './types';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
const localStorageConfig: RLSOptions = {
|
||||
states: ['settings', 'servers'],
|
||||
namespace: 'shlink',
|
||||
@@ -17,6 +14,12 @@ const localStorageConfig: RLSOptions = {
|
||||
};
|
||||
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||
|
||||
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||
));
|
||||
export const setUpStore = (container: IContainer) => configureStore({
|
||||
devTools: !isProduction,
|
||||
reducer: reducer(container),
|
||||
preloadedState,
|
||||
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
||||
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these
|
||||
.prepend(container.selectServerListener.middleware)
|
||||
.concat(save(localStorageConfig)),
|
||||
});
|
||||
|
||||
@@ -13,15 +13,15 @@ import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||
import { DomainsList } from '../domains/reducers/domainsList';
|
||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
import { VisitsInfo } from '../visits/types';
|
||||
import { Sidebar } from '../common/reducers/sidebar';
|
||||
import { DomainVisits } from '../visits/reducers/domainVisits';
|
||||
import { VisitsInfo } from '../visits/reducers/types';
|
||||
|
||||
export interface ShlinkState {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsList;
|
||||
shortUrlCreationResult: ShortUrlCreation;
|
||||
shortUrlCreation: ShortUrlCreation;
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
shortUrlVisits: ShortUrlVisits;
|
||||
|
||||
@@ -8,11 +8,12 @@ import { SelectedServer } from '../servers/data';
|
||||
import { Domain } from './data';
|
||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||
import { DomainDropdown } from './helpers/DomainDropdown';
|
||||
import { EditDomainRedirects } from './reducers/domainRedirects';
|
||||
|
||||
interface DomainRowProps {
|
||||
domain: Domain;
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { SearchField } from '../utils/SearchField';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { EditDomainRedirects } from './reducers/domainRedirects';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { DomainsList } from './reducers/domainsList';
|
||||
import { DomainRow } from './DomainRow';
|
||||
@@ -12,7 +12,7 @@ import { DomainRow } from './DomainRow';
|
||||
interface ManageDomainsProps {
|
||||
listDomains: Function;
|
||||
filterDomains: (searchTerm: string) => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
domainsList: DomainsList;
|
||||
selectedServer: SelectedServer;
|
||||
|
||||
@@ -7,14 +7,14 @@ import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
||||
import { Domain } from '../data';
|
||||
import { ShlinkDomainRedirects } from '../../api/types';
|
||||
import { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
|
||||
import { getServerId, SelectedServer } from '../../servers/data';
|
||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||
|
||||
interface DomainDropdownProps {
|
||||
domain: Domain;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { ShlinkDomain } from '../../api/types';
|
||||
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
import { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
||||
interface EditDomainRedirectsModalProps {
|
||||
domain: ShlinkDomain;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
}
|
||||
|
||||
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||
@@ -30,10 +31,13 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
|
||||
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||
);
|
||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({
|
||||
domain: domain.domain,
|
||||
redirects: {
|
||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||
},
|
||||
}).then(toggle));
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkDomainRedirects } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
||||
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
||||
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||
|
||||
export interface EditDomainRedirectsAction extends Action<string> {
|
||||
export interface EditDomainRedirects {
|
||||
domain: string;
|
||||
redirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
domain: string,
|
||||
domainRedirects: Partial<ShlinkDomainRedirects>,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
||||
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
|
||||
export const editDomainRedirects = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
) => createAsyncThunk(
|
||||
EDIT_DOMAIN_REDIRECTS,
|
||||
async ({ domain, redirects: providedRedirects }: EditDomainRedirects, { getState }): Promise<EditDomainRedirects> => {
|
||||
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
|
||||
const redirects = await shlinkEditDomainRedirects({ domain, ...providedRedirects });
|
||||
|
||||
try {
|
||||
const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
|
||||
|
||||
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
return { domain, redirects };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { createSlice, createAction, SliceCaseReducers, AsyncThunk } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkDomainRedirects } from '../../api/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { Domain, DomainStatus } from '../data';
|
||||
import { hasServerData } from '../../servers/data';
|
||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { EditDomainRedirects } from './domainRedirects';
|
||||
|
||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||
const REDUCER_PREFIX = 'shlink/domainsList';
|
||||
|
||||
export interface DomainsList {
|
||||
domains: Domain[];
|
||||
@@ -25,16 +20,12 @@ export interface DomainsList {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ListDomainsAction extends Action<string> {
|
||||
interface ListDomains {
|
||||
domains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
interface FilterDomainsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface ValidateDomain extends Action<string> {
|
||||
interface ValidateDomain {
|
||||
domain: string;
|
||||
status: DomainStatus;
|
||||
}
|
||||
@@ -46,83 +37,89 @@ const initialState: DomainsList = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
export type DomainsCombinedAction = ListDomainsAction
|
||||
& ApiErrorAction
|
||||
& FilterDomainsAction
|
||||
& EditDomainRedirectsAction
|
||||
& ValidateDomain;
|
||||
|
||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||
export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) =>
|
||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
|
||||
|
||||
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
||||
|
||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
|
||||
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
|
||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||
...state,
|
||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm.toLowerCase())),
|
||||
}),
|
||||
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
}),
|
||||
[VALIDATE_DOMAIN]: (state, { domain, status }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceStatusOnDomain(domain, status)),
|
||||
filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)),
|
||||
}),
|
||||
}, initialState);
|
||||
|
||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
export const domainsListReducerCreator = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
|
||||
) => {
|
||||
dispatch({ type: LIST_DOMAINS_START });
|
||||
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
||||
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => {
|
||||
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
||||
const { data, defaultRedirects } = await shlinkListDomains();
|
||||
|
||||
try {
|
||||
const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
|
||||
return {
|
||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||
defaultRedirects,
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||
|
||||
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
const { selectedServer } = getState();
|
||||
|
||||
if (!hasServerData(selectedServer)) {
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, ...rest } = selectedServer;
|
||||
const { health } = buildShlinkApiClient({
|
||||
...rest,
|
||||
url: replaceAuthorityFromUri(url, domain),
|
||||
});
|
||||
|
||||
const { status } = await health();
|
||||
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
|
||||
} catch (e) {
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||
}
|
||||
const checkDomainHealth = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/checkDomainHealth`,
|
||||
async (domain: string, { getState }): Promise<ValidateDomain> => {
|
||||
const { selectedServer } = getState();
|
||||
|
||||
if (!hasServerData(selectedServer)) {
|
||||
return { domain, status: 'invalid' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, ...rest } = selectedServer;
|
||||
const { health } = buildShlinkApiClient({
|
||||
...rest,
|
||||
url: replaceAuthorityFromUri(url, domain),
|
||||
});
|
||||
|
||||
const { status } = await health();
|
||||
|
||||
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
|
||||
} catch (e) {
|
||||
return { domain, status: 'invalid' };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const filterDomains = createAction<string>(`${REDUCER_PREFIX}/filterDomains`);
|
||||
|
||||
const { reducer } = createSlice<DomainsList, SliceCaseReducers<DomainsList>>({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true }));
|
||||
builder.addCase(listDomains.rejected, (_, { error }) => (
|
||||
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||
));
|
||||
builder.addCase(listDomains.fulfilled, (_, { payload }) => (
|
||||
{ ...initialState, ...payload, filteredDomains: payload.domains }
|
||||
));
|
||||
|
||||
builder.addCase(checkDomainHealth.fulfilled, ({ domains, filteredDomains, ...rest }, { payload }) => ({
|
||||
...rest,
|
||||
domains: domains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
||||
filteredDomains: filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
||||
}));
|
||||
|
||||
builder.addCase(filterDomains, (state, { payload }) => ({
|
||||
...state,
|
||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())),
|
||||
}));
|
||||
|
||||
builder.addCase(editDomainRedirects.fulfilled, (state, { payload }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceRedirectsOnDomain(payload)),
|
||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(payload)),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reducer,
|
||||
listDomains,
|
||||
checkDomainHealth,
|
||||
filterDomains,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prop } from 'ramda';
|
||||
import Bottle from 'bottlejs';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { domainsListReducerCreator } from '../reducers/domainsList';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
@@ -16,11 +17,20 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
||||
));
|
||||
|
||||
// Reducer
|
||||
bottle.serviceFactory(
|
||||
'domainsListReducerCreator',
|
||||
domainsListReducerCreator,
|
||||
'buildShlinkApiClient',
|
||||
'editDomainRedirects',
|
||||
);
|
||||
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
|
||||
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
@import './utils/base';
|
||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import './common/react-tag-autocomplete.scss';
|
||||
@import './theme/theme';
|
||||
@import './utils/theme/theme';
|
||||
@import './utils/mixins/text-ellipsis';
|
||||
@import './utils/table/ResponsiveTable';
|
||||
@import './utils/StickyCardPaginator';
|
||||
|
||||
@@ -39,6 +40,10 @@ a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.bt
|
||||
background-color: $mainColor !important;
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
color: $lightTextColor;
|
||||
}
|
||||
|
||||
.card-body,
|
||||
.card-header,
|
||||
.list-group-item {
|
||||
@@ -218,9 +223,7 @@ hr {
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
@include text-ellipsis();
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import pack from '../package.json';
|
||||
import { container } from './container';
|
||||
import { store } from './container/store';
|
||||
import { setUpStore } from './container/store';
|
||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||
import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking
|
||||
@@ -14,6 +14,7 @@ import './index.scss';
|
||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||
fixLeafletIcons();
|
||||
|
||||
const store = setUpStore(container);
|
||||
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||
|
||||
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
@@ -1,52 +1,44 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkMercureInfo } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
|
||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||
const REDUCER_PREFIX = 'shlink/mercure';
|
||||
|
||||
export interface MercureInfo {
|
||||
token?: string;
|
||||
mercureHubUrl?: string;
|
||||
export interface MercureInfo extends Partial<ShlinkMercureInfo> {
|
||||
interval?: number;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export type GetMercureInfoAction = Action<string> & ShlinkMercureInfo & { interval?: number };
|
||||
|
||||
const initialState: MercureInfo = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<MercureInfo, GetMercureInfoAction>({
|
||||
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||
[GET_MERCURE_INFO]: (_, action) => ({ ...action, loading: false, error: false }),
|
||||
}, initialState);
|
||||
export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
||||
const loadMercureInfo = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/loadMercureInfo`,
|
||||
(_: void, { getState }): Promise<ShlinkMercureInfo> => {
|
||||
const { settings } = getState();
|
||||
if (!settings.realTimeUpdates.enabled) {
|
||||
throw new Error('Real time updates not enabled');
|
||||
}
|
||||
|
||||
export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
() => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: GET_MERCURE_INFO_START });
|
||||
return buildShlinkApiClient(getState).mercureInfo();
|
||||
},
|
||||
);
|
||||
|
||||
const { settings } = getState();
|
||||
const { mercureInfo } = buildShlinkApiClient(getState);
|
||||
const { reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(loadMercureInfo.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||
builder.addCase(loadMercureInfo.rejected, (state) => ({ ...state, loading: false, error: true }));
|
||||
builder.addCase(loadMercureInfo.fulfilled, (_, { payload }) => ({ ...payload, loading: false, error: false }));
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings.realTimeUpdates.enabled) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await mercureInfo();
|
||||
|
||||
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info });
|
||||
} catch (e) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
}
|
||||
};
|
||||
return { loadMercureInfo, reducer };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { prop } from 'ramda';
|
||||
import Bottle from 'bottlejs';
|
||||
import { loadMercureInfo } from '../reducers/mercureInfo';
|
||||
import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
|
||||
|
||||
const provideServices = (bottle: Bottle) => {
|
||||
// Reducer
|
||||
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -1,47 +1,31 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import serversReducer from '../servers/reducers/servers';
|
||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||
import domainVisitsReducer from '../visits/reducers/domainVisits';
|
||||
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
||||
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
||||
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||
import settingsReducer from '../settings/reducers/settings';
|
||||
import domainsListReducer from '../domains/reducers/domainsList';
|
||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||
import appUpdatesReducer from '../app/reducers/appUpdates';
|
||||
import sidebarReducer from '../common/reducers/sidebar';
|
||||
import { IContainer } from 'bottlejs';
|
||||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
import { serversReducer } from '../servers/reducers/servers';
|
||||
import { settingsReducer } from '../settings/reducers/settings';
|
||||
import { appUpdatesReducer } from '../app/reducers/appUpdates';
|
||||
import { sidebarReducer } from '../common/reducers/sidebar';
|
||||
import { ShlinkState } from '../container/types';
|
||||
|
||||
export default combineReducers<ShlinkState>({
|
||||
export default (container: IContainer) => combineReducers<ShlinkState>({
|
||||
servers: serversReducer,
|
||||
selectedServer: selectedServerReducer,
|
||||
shortUrlsList: shortUrlsListReducer,
|
||||
shortUrlCreationResult: shortUrlCreationReducer,
|
||||
shortUrlDeletion: shortUrlDeletionReducer,
|
||||
shortUrlEdition: shortUrlEditionReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
tagVisits: tagVisitsReducer,
|
||||
domainVisits: domainVisitsReducer,
|
||||
orphanVisits: orphanVisitsReducer,
|
||||
nonOrphanVisits: nonOrphanVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
tagDelete: tagDeleteReducer,
|
||||
tagEdit: tagEditReducer,
|
||||
mercureInfo: mercureInfoReducer,
|
||||
selectedServer: container.selectedServerReducer,
|
||||
shortUrlsList: container.shortUrlsListReducer,
|
||||
shortUrlCreation: container.shortUrlCreationReducer,
|
||||
shortUrlDeletion: container.shortUrlDeletionReducer,
|
||||
shortUrlEdition: container.shortUrlEditionReducer,
|
||||
shortUrlDetail: container.shortUrlDetailReducer,
|
||||
shortUrlVisits: container.shortUrlVisitsReducer,
|
||||
tagVisits: container.tagVisitsReducer,
|
||||
domainVisits: container.domainVisitsReducer,
|
||||
orphanVisits: container.orphanVisitsReducer,
|
||||
nonOrphanVisits: container.nonOrphanVisitsReducer,
|
||||
tagsList: container.tagsListReducer,
|
||||
tagDelete: container.tagDeleteReducer,
|
||||
tagEdit: container.tagEditReducer,
|
||||
mercureInfo: container.mercureInfoReducer,
|
||||
settings: settingsReducer,
|
||||
domainsList: domainsListReducer,
|
||||
visitsOverview: visitsOverviewReducer,
|
||||
domainsList: container.domainsListReducer,
|
||||
visitsOverview: container.visitsOverviewReducer,
|
||||
appUpdated: appUpdatesReducer,
|
||||
sidebar: sidebarReducer,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
interface CreateServerProps {
|
||||
createServer: (server: ServerWithId) => void;
|
||||
createServers: (servers: ServerWithId[]) => void;
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||
);
|
||||
|
||||
export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTimeoutToggle: TimeoutToggle) => (
|
||||
{ servers, createServer }: CreateServerProps,
|
||||
{ servers, createServers }: CreateServerProps,
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const goBack = useGoBack();
|
||||
@@ -43,7 +43,7 @@ export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTim
|
||||
|
||||
const id = uuid();
|
||||
|
||||
createServer({ ...serverData, id });
|
||||
createServers([{ ...serverData, id }]);
|
||||
navigate(`/server/${id}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useRef } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ServerWithId } from './data';
|
||||
@@ -18,14 +18,22 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const closeModal = () => {
|
||||
deleteServer(server);
|
||||
const doDelete = useRef<boolean>(false);
|
||||
const toggleAndDelete = () => {
|
||||
doDelete.current = true;
|
||||
toggle();
|
||||
};
|
||||
const onClosed = () => {
|
||||
if (!doDelete.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteServer(server);
|
||||
redirectHome && navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||
@@ -38,7 +46,7 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="danger" onClick={() => closeModal()}>Delete</Button>
|
||||
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { isServerWithId, ServerData } from './data';
|
||||
@@ -10,8 +10,11 @@ interface EditServerProps {
|
||||
editServer: (serverId: string, serverData: ServerData) => void;
|
||||
}
|
||||
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||
{ editServer, selectedServer, selectServer },
|
||||
) => {
|
||||
const goBack = useGoBack();
|
||||
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
|
||||
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return null;
|
||||
@@ -19,6 +22,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
||||
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
reconnect === 'true' && selectServer(selectedServer.id);
|
||||
goBack();
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { TagsList } from '../tags/reducers/tagsList';
|
||||
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
||||
import { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
@@ -25,7 +25,7 @@ interface OverviewConnectProps {
|
||||
}
|
||||
|
||||
export const Overview = (
|
||||
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
||||
ShortUrlsTable: ShortUrlsTableType,
|
||||
CreateShortUrl: FC<CreateShortUrlProps>,
|
||||
) => boundToMercureHub(({
|
||||
shortUrlsList,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC
|
||||
<h5>
|
||||
Alternatively, if you think you may have miss-configured this server, you
|
||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
|
||||
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||
</h5>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { pipe, prop } from 'ramda';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import pack from '../../../package.json';
|
||||
import { hasServerData, ServerData } from '../data';
|
||||
import { createServers } from './servers';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { HttpClient } from '../../common/services/HttpClient';
|
||||
|
||||
const responseToServersList = pipe(
|
||||
prop<any, any>('data'),
|
||||
(data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []),
|
||||
const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []);
|
||||
|
||||
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
|
||||
'shlink/remoteServers/fetchServers',
|
||||
async (_: void, { dispatch }): Promise<void> => {
|
||||
const resp = await httpClient.fetchJson<any>(`${pack.homepage}/servers.json`);
|
||||
const result = responseToServersList(resp);
|
||||
|
||||
dispatch(createServers(result));
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||
const resp = await get(`${pack.homepage}/servers.json`);
|
||||
const remoteList = responseToServersList(resp);
|
||||
|
||||
dispatch(createServers(remoteList));
|
||||
};
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { identity, memoizeWith, pipe } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { createAction, createListenerMiddleware, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { memoizeWith, pipe } from 'ramda';
|
||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||
import { SelectedServer } from '../data';
|
||||
import { GetState } from '../../container/types';
|
||||
import { isReachableServer, SelectedServer, ServerWithId } from '../data';
|
||||
import { ShlinkHealth } from '../../api/types';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
||||
const REDUCER_PREFIX = 'shlink/selectedServer';
|
||||
|
||||
export const MIN_FALLBACK_VERSION = '1.0.0';
|
||||
export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||
|
||||
export interface SelectServerAction extends Action<string> {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const versionToSemVer = pipe(
|
||||
(version: string) => (version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version),
|
||||
toSemVer(MIN_FALLBACK_VERSION),
|
||||
);
|
||||
|
||||
const getServerVersion = memoizeWith(
|
||||
identity,
|
||||
async (_serverId: string, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
|
||||
(server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`,
|
||||
async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
|
||||
version: versionToSemVer(version),
|
||||
printableVersion: versionToPrintable(version),
|
||||
})),
|
||||
@@ -33,53 +27,59 @@ const getServerVersion = memoizeWith(
|
||||
|
||||
const initialState: SelectedServer = null;
|
||||
|
||||
export default buildReducer<SelectedServer, SelectServerAction>({
|
||||
[RESET_SELECTED_SERVER]: () => initialState,
|
||||
[SELECT_SERVER]: (_, { selectedServer }) => selectedServer,
|
||||
}, initialState);
|
||||
export const resetSelectedServer = createAction<void>(`${REDUCER_PREFIX}/resetSelectedServer`);
|
||||
|
||||
export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER);
|
||||
export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/selectServer`,
|
||||
async (serverId: string, { dispatch, getState }): Promise<SelectedServer> => {
|
||||
dispatch(resetSelectedServer());
|
||||
|
||||
export const selectServer = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
loadMercureInfo: () => Action,
|
||||
) => (
|
||||
serverId: string,
|
||||
) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
dispatch(resetSelectedServer());
|
||||
const { servers } = getState();
|
||||
const selectedServer = servers[serverId];
|
||||
|
||||
const { servers } = getState();
|
||||
const selectedServer = servers[serverId];
|
||||
if (!selectedServer) {
|
||||
return { serverNotFound: true };
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { serverNotFound: true },
|
||||
});
|
||||
try {
|
||||
const { health } = buildShlinkApiClient(selectedServer);
|
||||
const { version, printableVersion } = await getServerVersion(selectedServer, health);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { health } = buildShlinkApiClient(selectedServer);
|
||||
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: {
|
||||
return {
|
||||
...selectedServer,
|
||||
version,
|
||||
printableVersion,
|
||||
},
|
||||
});
|
||||
dispatch(loadMercureInfo());
|
||||
} catch (e) {
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { ...selectedServer, serverNotReachable: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return { ...selectedServer, serverNotReachable: true };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type SelectServerThunk = ReturnType<typeof selectServer>;
|
||||
|
||||
export const selectServerListener = (
|
||||
selectServerThunk: SelectServerThunk,
|
||||
loadMercureInfo: () => PayloadAction<any>, // TODO Consider setting actual type, if relevant
|
||||
) => {
|
||||
const listener = createListenerMiddleware();
|
||||
|
||||
listener.startListening({
|
||||
actionCreator: selectServerThunk.fulfilled,
|
||||
effect: ({ payload }, { dispatch }) => {
|
||||
isReachableServer(payload) && dispatch(loadMercureInfo());
|
||||
},
|
||||
});
|
||||
|
||||
return listener;
|
||||
};
|
||||
|
||||
export const selectedServerReducerCreator = (selectServerThunk: SelectServerThunk) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(resetSelectedServer, () => initialState);
|
||||
builder.addCase(selectServerThunk.fulfilled, (_, { payload }) => payload as any);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Action } from 'redux';
|
||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
|
||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
||||
|
||||
export interface CreateServersAction extends Action<string> {
|
||||
newServers: ServersMap;
|
||||
}
|
||||
|
||||
interface DeleteServerAction extends Action<string> {
|
||||
interface EditServer {
|
||||
serverId: string;
|
||||
serverData: Partial<ServerData>;
|
||||
}
|
||||
|
||||
interface SetAutoConnectAction extends Action<string> {
|
||||
interface SetAutoConnect {
|
||||
serverId: string;
|
||||
autoConnect: boolean;
|
||||
}
|
||||
@@ -32,50 +23,57 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
return assoc('id', uuid(), server);
|
||||
};
|
||||
|
||||
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => (
|
||||
!state[serverId] ? state : assoc(serverId, { ...state[serverId], ...serverData }, state)
|
||||
),
|
||||
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
|
||||
if (!state[serverId]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!autoConnect) {
|
||||
return assoc(serverId, { ...state[serverId], autoConnect }, state);
|
||||
}
|
||||
|
||||
return fromPairs(
|
||||
toPairs(state).map(([evaluatedServerId, server]) => [
|
||||
evaluatedServerId,
|
||||
{ ...server, autoConnect: evaluatedServerId === serverId },
|
||||
]),
|
||||
);
|
||||
},
|
||||
}, initialState);
|
||||
|
||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||
|
||||
export const createServers = pipe(
|
||||
map(serverWithId),
|
||||
serversListToMap,
|
||||
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
|
||||
);
|
||||
export const { actions, reducer } = createSlice({
|
||||
name: 'shlink/servers',
|
||||
initialState,
|
||||
reducers: {
|
||||
editServer: {
|
||||
prepare: (serverId: string, serverData: Partial<ServerData>) => ({
|
||||
payload: { serverId, serverData },
|
||||
}),
|
||||
reducer: (state, { payload }: PayloadAction<EditServer>) => {
|
||||
const { serverId, serverData } = payload;
|
||||
return (
|
||||
!state[serverId] ? state : assoc(serverId, { ...state[serverId], ...serverData }, state)
|
||||
);
|
||||
},
|
||||
},
|
||||
deleteServer: (state, { payload }) => dissoc(payload.id, state),
|
||||
setAutoConnect: {
|
||||
prepare: ({ id: serverId }: ServerWithId, autoConnect: boolean) => ({
|
||||
payload: { serverId, autoConnect },
|
||||
}),
|
||||
reducer: (state, { payload }: PayloadAction<SetAutoConnect>) => {
|
||||
const { serverId, autoConnect } = payload;
|
||||
if (!state[serverId]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
export const createServer = (server: ServerWithId) => createServers([server]);
|
||||
if (!autoConnect) {
|
||||
return assoc(serverId, { ...state[serverId], autoConnect }, state);
|
||||
}
|
||||
|
||||
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
|
||||
type: EDIT_SERVER,
|
||||
serverId,
|
||||
serverData,
|
||||
return fromPairs(
|
||||
toPairs(state).map(([evaluatedServerId, server]) => [
|
||||
evaluatedServerId,
|
||||
{ ...server, autoConnect: evaluatedServerId === serverId },
|
||||
]),
|
||||
);
|
||||
},
|
||||
},
|
||||
createServers: {
|
||||
prepare: pipe(
|
||||
map(serverWithId),
|
||||
serversListToMap,
|
||||
(payload: ServersMap) => ({ payload }),
|
||||
),
|
||||
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
|
||||
export const { editServer, deleteServer, setAutoConnect, createServers } = actions;
|
||||
|
||||
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
|
||||
type: SET_AUTO_CONNECT,
|
||||
serverId: id,
|
||||
autoConnect,
|
||||
});
|
||||
export const serversReducer = reducer;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prop } from 'ramda';
|
||||
import Bottle from 'bottlejs';
|
||||
import { CreateServer } from '../CreateServer';
|
||||
import { ServersDropdown } from '../ServersDropdown';
|
||||
@@ -5,8 +6,13 @@ import { DeleteServerModal } from '../DeleteServerModal';
|
||||
import { DeleteServerButton } from '../DeleteServerButton';
|
||||
import { EditServer } from '../EditServer';
|
||||
import { ImportServersBtn } from '../helpers/ImportServersBtn';
|
||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||
import {
|
||||
resetSelectedServer,
|
||||
selectedServerReducerCreator,
|
||||
selectServer,
|
||||
selectServerListener,
|
||||
} from '../reducers/selectedServer';
|
||||
import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||
import { fetchServers } from '../reducers/remoteServers';
|
||||
import { ServerError } from '../helpers/ServerError';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
@@ -38,7 +44,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useTimeoutToggle');
|
||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServer', 'resetSelectedServer']));
|
||||
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServers', 'resetSelectedServer']));
|
||||
|
||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||
bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
|
||||
@@ -70,14 +76,18 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
||||
bottle.serviceFactory('createServer', () => createServer);
|
||||
bottle.serviceFactory('createServers', () => createServers);
|
||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||
bottle.serviceFactory('editServer', () => editServer);
|
||||
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||
bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
|
||||
// Reducers
|
||||
bottle.serviceFactory('selectServerListener', selectServerListener, 'selectServer', 'loadMercureInfo');
|
||||
bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer');
|
||||
bottle.serviceFactory('selectedServerReducer', prop('reducer'), 'selectedServerReducerCreator');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -12,12 +12,13 @@ import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||
import pack from '../package.json';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
clientsClaim();
|
||||
|
||||
// Precache all of the assets generated by your build process.
|
||||
// Precache all the assets generated by your build process.
|
||||
// Their URLs are injected into the manifest variable below.
|
||||
// This variable must be present somewhere in your service worker file,
|
||||
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||
@@ -49,7 +50,7 @@ registerRoute(
|
||||
// Return true to signal that we want to use the handler.
|
||||
return true;
|
||||
},
|
||||
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||
createHandlerBoundToURL(`${pack.homepage}/index.html`)
|
||||
);
|
||||
|
||||
// An example runtime caching route for requests that aren't handled by the
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://cra.link/PWA
|
||||
import pack from'../package.json';
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
@@ -26,7 +27,7 @@ type Config = {
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href);
|
||||
const publicUrl = new URL(pack.homepage, window.location.href);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
@@ -35,7 +36,7 @@ export function register(config?: Config) {
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
const swUrl = `${pack.homepage}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||
import { capitalize } from '../utils/utils';
|
||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
|
||||
import { FormText } from '../utils/forms/FormText';
|
||||
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
|
||||
|
||||
@@ -15,14 +12,6 @@ interface TagsProps {
|
||||
|
||||
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
|
||||
<SimpleCard title="Tags" className="h-100">
|
||||
<LabeledFormGroup label="Default display mode when managing tags:">
|
||||
<TagsModeDropdown
|
||||
mode={tags?.defaultMode ?? 'cards'}
|
||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
|
||||
/>
|
||||
<FormText>Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</FormText>
|
||||
</LabeledFormGroup>
|
||||
<LabeledFormGroup noMargin label="Default ordering for tags list:">
|
||||
<OrderingDropdown
|
||||
items={TAGS_ORDERABLE_FIELDS}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { FC } from 'react';
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
||||
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
|
||||
import { ToggleSwitch } from '../utils/ToggleSwitch';
|
||||
import { FormText } from '../utils/forms/FormText';
|
||||
import { DateInterval } from '../utils/helpers/dateIntervals';
|
||||
|
||||
interface VisitsProps {
|
||||
settings: Settings;
|
||||
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
|
||||
}
|
||||
|
||||
const currentDefaultInterval = (settings: Settings): DateInterval => settings.visits?.defaultInterval ?? 'last30Days';
|
||||
|
||||
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||
<SimpleCard title="Visits" className="h-100">
|
||||
<FormGroup>
|
||||
<ToggleSwitch
|
||||
checked={!!settings.visits?.excludeBots}
|
||||
onChange={(excludeBots) => setVisitsSettings(
|
||||
{ defaultInterval: currentDefaultInterval(settings), excludeBots },
|
||||
)}
|
||||
>
|
||||
Exclude bots wherever possible (this option‘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:">
|
||||
<DateIntervalSelector
|
||||
allText="All visits"
|
||||
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
||||
active={currentDefaultInterval(settings)}
|
||||
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
||||
/>
|
||||
</LabeledFormGroup>
|
||||
|
||||
@@ -11,12 +11,5 @@ export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<
|
||||
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
|
||||
}
|
||||
|
||||
// The "tags display mode" option has been moved from "ui" to "tags"
|
||||
state.settings.tags = {
|
||||
...state.settings.tags,
|
||||
defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode,
|
||||
};
|
||||
state.settings.ui && delete (state.settings.ui as any).tagsMode;
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Action } from 'redux';
|
||||
import { dissoc, mergeDeepRight } from 'ramda';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { RecursivePartial } from '../../utils/utils';
|
||||
import { createSlice, PayloadAction, PrepareAction } from '@reduxjs/toolkit';
|
||||
import { mergeDeepRight } from 'ramda';
|
||||
import { Theme } from '../../utils/theme';
|
||||
import { DateInterval } from '../../utils/dates/types';
|
||||
import { DateInterval } from '../../utils/helpers/dateIntervals';
|
||||
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
||||
import { ShortUrlsOrder } from '../../short-urls/data';
|
||||
|
||||
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
||||
|
||||
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||
field: 'dateCreated',
|
||||
dir: 'DESC',
|
||||
@@ -32,19 +28,17 @@ export interface ShortUrlCreationSettings {
|
||||
forwardQuery?: boolean;
|
||||
}
|
||||
|
||||
export type TagsMode = 'cards' | 'list';
|
||||
|
||||
export interface UiSettings {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export interface VisitsSettings {
|
||||
defaultInterval: DateInterval;
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface TagsSettings {
|
||||
defaultOrdering?: TagsOrder;
|
||||
defaultMode?: TagsMode;
|
||||
}
|
||||
|
||||
export interface ShortUrlsListSettings {
|
||||
@@ -78,45 +72,37 @@ const initialState: Settings = {
|
||||
},
|
||||
};
|
||||
|
||||
type SettingsAction = Action & Settings;
|
||||
type SettingsAction = PayloadAction<Settings>;
|
||||
type SettingsPrepareAction = PrepareAction<Settings>;
|
||||
|
||||
type PartialSettingsAction = Action & RecursivePartial<Settings>;
|
||||
const commonReducer = (state: Settings, { payload }: SettingsAction) => mergeDeepRight(state, payload);
|
||||
const toReducer = (prepare: SettingsPrepareAction) => ({ reducer: commonReducer, prepare });
|
||||
const toPreparedAction: SettingsPrepareAction = (payload: Settings) => ({ payload });
|
||||
|
||||
export default buildReducer<Settings, SettingsAction>({
|
||||
[SET_SETTINGS]: (state, action) => mergeDeepRight(state, dissoc('type', action)),
|
||||
}, initialState);
|
||||
|
||||
export const toggleRealTimeUpdates = (enabled: boolean): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
realTimeUpdates: { enabled },
|
||||
const { reducer, actions } = createSlice({
|
||||
name: 'shlink/settings',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleRealTimeUpdates: toReducer((enabled: boolean) => toPreparedAction({ realTimeUpdates: { enabled } })),
|
||||
setRealTimeUpdatesInterval: toReducer((interval: number) => toPreparedAction({ realTimeUpdates: { interval } })),
|
||||
setShortUrlCreationSettings: toReducer(
|
||||
(shortUrlCreation: ShortUrlCreationSettings) => toPreparedAction({ shortUrlCreation }),
|
||||
),
|
||||
setShortUrlsListSettings: toReducer((shortUrlsList: ShortUrlsListSettings) => toPreparedAction({ shortUrlsList })),
|
||||
setUiSettings: toReducer((ui: UiSettings) => toPreparedAction({ ui })),
|
||||
setVisitsSettings: toReducer((visits: VisitsSettings) => toPreparedAction({ visits })),
|
||||
setTagsSettings: toReducer((tags: TagsSettings) => toPreparedAction({ tags })),
|
||||
},
|
||||
});
|
||||
|
||||
export const setRealTimeUpdatesInterval = (interval: number): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
realTimeUpdates: { interval },
|
||||
});
|
||||
export const {
|
||||
toggleRealTimeUpdates,
|
||||
setRealTimeUpdatesInterval,
|
||||
setShortUrlCreationSettings,
|
||||
setShortUrlsListSettings,
|
||||
setUiSettings,
|
||||
setVisitsSettings,
|
||||
setTagsSettings,
|
||||
} = actions;
|
||||
|
||||
export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
shortUrlCreation: settings,
|
||||
});
|
||||
|
||||
export const setShortUrlsListSettings = (settings: ShortUrlsListSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
shortUrlsList: settings,
|
||||
});
|
||||
|
||||
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
ui: settings,
|
||||
});
|
||||
|
||||
export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
visits: settings,
|
||||
});
|
||||
|
||||
export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
tags: settings,
|
||||
});
|
||||
export const settingsReducer = reducer;
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface CreateShortUrlProps {
|
||||
|
||||
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
||||
settings: Settings;
|
||||
shortUrlCreationResult: ShortUrlCreation;
|
||||
shortUrlCreation: ShortUrlCreation;
|
||||
selectedServer: SelectedServer;
|
||||
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
||||
resetCreateShortUrl: () => void;
|
||||
@@ -38,7 +38,7 @@ export const CreateShortUrl = (
|
||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||
) => ({
|
||||
createShortUrl,
|
||||
shortUrlCreationResult,
|
||||
shortUrlCreation,
|
||||
resetCreateShortUrl,
|
||||
selectedServer,
|
||||
basicMode = false,
|
||||
@@ -50,7 +50,7 @@ export const CreateShortUrl = (
|
||||
<>
|
||||
<ShortUrlForm
|
||||
initialState={initialState}
|
||||
saving={shortUrlCreationResult.saving}
|
||||
saving={shortUrlCreation.saving}
|
||||
selectedServer={selectedServer}
|
||||
mode={basicMode ? 'create-basic' : 'create'}
|
||||
onSave={async (data: ShortUrlData) => {
|
||||
@@ -59,7 +59,7 @@ export const CreateShortUrl = (
|
||||
}}
|
||||
/>
|
||||
<CreateShortUrlResult
|
||||
{...shortUrlCreationResult}
|
||||
creation={shortUrlCreation}
|
||||
resetCreateShortUrl={resetCreateShortUrl}
|
||||
canBeClosed={basicMode}
|
||||
/>
|
||||
|
||||
@@ -6,16 +6,15 @@ import { ExternalLink } from 'react-external-link';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import { OptionalString } from '../utils/utils';
|
||||
import { ShortUrlIdentifier } from './data';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { Message } from '../utils/Message';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { useGoBack, useToggle } from '../utils/helpers/hooks';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { ShortUrlFormProps } from './ShortUrlForm';
|
||||
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||
import { EditShortUrlData } from './data';
|
||||
import { ShortUrlEdition } from './reducers/shortUrlEdition';
|
||||
import { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition';
|
||||
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
||||
|
||||
interface EditShortUrlConnectProps {
|
||||
@@ -23,8 +22,8 @@ interface EditShortUrlConnectProps {
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
getShortUrlDetail: (shortCode: string, domain: OptionalString) => void;
|
||||
editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise<void>;
|
||||
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
|
||||
}
|
||||
|
||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
@@ -39,16 +38,15 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
const params = useParams<{ shortCode: string }>();
|
||||
const goBack = useGoBack();
|
||||
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
||||
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||
const initialState = useMemo(
|
||||
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
|
||||
[shortUrl, shortUrlCreationSettings],
|
||||
);
|
||||
const [savingSucceeded,, isSuccessful, isNotSuccessful] = useToggle();
|
||||
|
||||
useEffect(() => {
|
||||
params.shortCode && getShortUrlDetail(urlDecodeShortCode(params.shortCode), domain);
|
||||
params.shortCode && getShortUrlDetail({ shortCode: urlDecodeShortCode(params.shortCode), domain });
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
@@ -88,18 +86,15 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
isNotSuccessful();
|
||||
editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)
|
||||
.then(isSuccessful)
|
||||
.catch(isNotSuccessful);
|
||||
editShortUrl({ ...shortUrl, data: shortUrlData });
|
||||
}}
|
||||
/>
|
||||
{savingError && (
|
||||
{saved && savingError && (
|
||||
<Result type="error" className="mt-3">
|
||||
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
|
||||
</Result>
|
||||
)}
|
||||
{savingSucceeded && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
|
||||
{saved && !savingError && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
|
||||
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
return null;
|
||||
return <div className="pb-3" />; // Return some space
|
||||
}
|
||||
|
||||
const renderPages = () =>
|
||||
@@ -38,7 +38,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
|
||||
));
|
||||
|
||||
return (
|
||||
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<Pagination className="sticky-card-paginator py-3" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<PaginationItem disabled={currentPage === 1}>
|
||||
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { InputType } from 'reactstrap/types/lib/Input';
|
||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { DateInput, DateInputProps } from '../utils/DateInput';
|
||||
import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features';
|
||||
import { DateTimeInput, DateTimeInputProps } from '../utils/dates/DateTimeInput';
|
||||
import { supportsForwardQuery } from '../utils/helpers/features';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
|
||||
import { Checkbox } from '../utils/Checkbox';
|
||||
@@ -83,8 +83,8 @@ export const ShortUrlForm = (
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
||||
<DateInput
|
||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => (
|
||||
<DateTimeInput
|
||||
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
@@ -113,16 +113,14 @@ export const ShortUrlForm = (
|
||||
</>
|
||||
);
|
||||
|
||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||
const showForwardQueryControl = supportsForwardQuery(selectedServer);
|
||||
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
|
||||
|
||||
return (
|
||||
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
|
||||
{isBasicMode && basicComponents}
|
||||
{!isBasicMode && (
|
||||
<>
|
||||
<SimpleCard title="Basic options" className="mb-3">
|
||||
<SimpleCard title="Main options" className="mb-3">
|
||||
{basicComponents}
|
||||
</SimpleCard>
|
||||
|
||||
@@ -190,30 +188,26 @@ export const ShortUrlForm = (
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
{showBehaviorCard && (
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Configure behavior">
|
||||
{showCrawlableControl && (
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
||||
checked={shortUrlData.crawlable}
|
||||
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
||||
>
|
||||
Make it crawlable
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
)}
|
||||
{showForwardQueryControl && (
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||
checked={shortUrlData.forwardQuery}
|
||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||
>
|
||||
Forward query params on redirect
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Configure behavior">
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
||||
checked={shortUrlData.crawlable}
|
||||
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
||||
>
|
||||
Make it crawlable
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
{showForwardQueryControl && (
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||
checked={shortUrlData.forwardQuery}
|
||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||
>
|
||||
Forward query params on redirect
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FC } from 'react';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
@@ -8,8 +7,8 @@ import classNames from 'classnames';
|
||||
import { SearchField } from '../utils/SearchField';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import { DateRange } from '../utils/dates/types';
|
||||
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
||||
import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals';
|
||||
import { supportsAllTagsFiltering, supportsFilterDisabledUrls } from '../utils/helpers/features';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { OrderDir } from '../utils/helpers/ordering';
|
||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||
@@ -17,23 +16,36 @@ import { useShortUrlsQuery } from './helpers/hooks';
|
||||
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import './ShortUrlsFilteringBar.scss';
|
||||
|
||||
export interface ShortUrlsFilteringProps {
|
||||
interface ShortUrlsFilteringProps {
|
||||
selectedServer: SelectedServer;
|
||||
order: ShortUrlsOrder;
|
||||
settings: Settings;
|
||||
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
||||
className?: string;
|
||||
shortUrlsAmount?: number;
|
||||
}
|
||||
|
||||
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
|
||||
|
||||
export const ShortUrlsFilteringBar = (
|
||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
|
||||
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery();
|
||||
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
|
||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||
const {
|
||||
search,
|
||||
tags,
|
||||
startDate,
|
||||
endDate,
|
||||
excludeBots,
|
||||
excludeMaxVisitsReached,
|
||||
excludePastValidUntil,
|
||||
tagsMode = 'any',
|
||||
} = filter;
|
||||
const supportsDisabledFiltering = supportsFilterDisabledUrls(selectedServer);
|
||||
|
||||
const setDates = pipe(
|
||||
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||
startDate: formatIsoDate(theStartDate) ?? undefined,
|
||||
@@ -72,14 +84,25 @@ export const ShortUrlsFilteringBar = (
|
||||
|
||||
<Row className="flex-lg-row-reverse">
|
||||
<div className="col-lg-8 col-xl-6 mt-3">
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={{
|
||||
startDate: dateOrNull(startDate),
|
||||
endDate: dateOrNull(endDate),
|
||||
}}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
<div className="d-md-flex">
|
||||
<div className="flex-fill">
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={datesToDateRange(startDate, endDate)}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
<ShortUrlsFilterDropdown
|
||||
className="ms-0 ms-md-2 mt-3 mt-md-0"
|
||||
selected={{
|
||||
excludeBots: excludeBots ?? settings.visits?.excludeBots,
|
||||
excludeMaxVisitsReached,
|
||||
excludePastValidUntil,
|
||||
}}
|
||||
onChange={toFirstPage}
|
||||
supportsDisabledFiltering={supportsDisabledFiltering}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-lg-4 col-xl-6 mt-3">
|
||||
<ExportShortUrlsBtn amount={shortUrlsAmount} />
|
||||
@@ -96,3 +119,5 @@ export const ShortUrlsFilteringBar = (
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ShortUrlsFilteringBarType = ReturnType<typeof ShortUrlsFilteringBar>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pipe } from 'ramda';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
|
||||
@@ -7,14 +7,15 @@ import { getServerId, SelectedServer } from '../servers/data';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||
import { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types';
|
||||
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
|
||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||
import { ShortUrlsTableType } from './ShortUrlsTable';
|
||||
import { Paginator } from './Paginator';
|
||||
import { useShortUrlsQuery } from './helpers/hooks';
|
||||
import { ShortUrlsOrderableFields } from './data';
|
||||
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
|
||||
import { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
|
||||
import { supportsExcludeBotsOnShortUrls } from '../utils/helpers/features';
|
||||
|
||||
interface ShortUrlsListProps {
|
||||
selectedServer: SelectedServer;
|
||||
@@ -24,18 +25,30 @@ interface ShortUrlsListProps {
|
||||
}
|
||||
|
||||
export const ShortUrlsList = (
|
||||
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
||||
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
|
||||
ShortUrlsTable: ShortUrlsTableType,
|
||||
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
|
||||
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
|
||||
const serverId = getServerId(selectedServer);
|
||||
const { page } = useParams();
|
||||
const location = useLocation();
|
||||
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage] = useShortUrlsQuery();
|
||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||
const {
|
||||
tags,
|
||||
search,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy,
|
||||
tagsMode,
|
||||
excludeBots,
|
||||
excludePastValidUntil,
|
||||
excludeMaxVisitsReached,
|
||||
} = filter;
|
||||
const [actualOrderBy, setActualOrderBy] = useState(
|
||||
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
||||
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
||||
);
|
||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
|
||||
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||
toFirstPage({ orderBy: { field, dir } });
|
||||
setActualOrderBy({ field, dir });
|
||||
@@ -48,6 +61,13 @@ export const ShortUrlsList = (
|
||||
(newTag: string) => [...new Set([...tags, newTag])],
|
||||
(updatedTags) => toFirstPage({ tags: updatedTags }),
|
||||
);
|
||||
const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => {
|
||||
if (supportsExcludeBotsOnShortUrls(selectedServer) && doExcludeBots && field === 'visits') {
|
||||
return { field: 'nonBotVisits', dir };
|
||||
}
|
||||
|
||||
return { field, dir };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listShortUrls({
|
||||
@@ -56,10 +76,23 @@ export const ShortUrlsList = (
|
||||
tags,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy: actualOrderBy,
|
||||
orderBy: parseOrderByForShlink(actualOrderBy),
|
||||
tagsMode,
|
||||
excludePastValidUntil,
|
||||
excludeMaxVisitsReached,
|
||||
});
|
||||
}, [page, search, tags, startDate, endDate, actualOrderBy, tagsMode]);
|
||||
}, [
|
||||
page,
|
||||
search,
|
||||
tags,
|
||||
startDate,
|
||||
endDate,
|
||||
actualOrderBy.field,
|
||||
actualOrderBy.dir,
|
||||
tagsMode,
|
||||
excludePastValidUntil,
|
||||
excludeMaxVisitsReached,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -68,9 +101,10 @@ export const ShortUrlsList = (
|
||||
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
||||
order={actualOrderBy}
|
||||
handleOrderBy={handleOrderBy}
|
||||
settings={settings}
|
||||
className="mb-3"
|
||||
/>
|
||||
<Card body className="pb-1">
|
||||
<Card body className="pb-0">
|
||||
<ShortUrlsTable
|
||||
selectedServer={selectedServer}
|
||||
shortUrlsList={shortUrlsList}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.short-urls-table.short-urls-table {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.short-urls-table__header-cell--with-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { isEmpty } from 'ramda';
|
||||
import classNames from 'classnames';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
||||
import { ShortUrlsRowType } from './helpers/ShortUrlsRow';
|
||||
import { ShortUrlsOrderableFields } from './data';
|
||||
import './ShortUrlsTable.scss';
|
||||
|
||||
export interface ShortUrlsTableProps {
|
||||
interface ShortUrlsTableProps {
|
||||
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
@@ -16,7 +16,7 @@ export interface ShortUrlsTableProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||
export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
|
||||
orderByColumn,
|
||||
renderOrderIcon,
|
||||
shortUrlsList,
|
||||
@@ -27,7 +27,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||
const { error, loading, shortUrls } = shortUrlsList;
|
||||
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
||||
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
|
||||
const tableClasses = classNames('table table-hover responsive-table', className);
|
||||
const tableClasses = classNames('table table-hover responsive-table short-urls-table', className);
|
||||
|
||||
const renderShortUrls = () => {
|
||||
if (error) {
|
||||
@@ -81,7 +81,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||
</th>
|
||||
<th className="short-urls-table__header-cell"> </th>
|
||||
<th className="short-urls-table__header-cell" colSpan={2} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -90,3 +90,5 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export type ShortUrlsTableType = ReturnType<typeof ShortUrlsTable>;
|
||||
|
||||
@@ -21,12 +21,19 @@ export interface ShortUrlData extends EditShortUrlData {
|
||||
findIfExists?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortUrlIdentifier {
|
||||
shortCode: string;
|
||||
domain?: OptionalString;
|
||||
}
|
||||
|
||||
export interface ShortUrl {
|
||||
shortCode: string;
|
||||
shortUrl: string;
|
||||
longUrl: string;
|
||||
dateCreated: string;
|
||||
visitsCount: number;
|
||||
/** @deprecated */
|
||||
visitsCount: number; // Deprecated since Shlink 3.4.0
|
||||
visitsSummary?: ShortUrlVisitsSummary; // Optional only before Shlink 3.4.0
|
||||
meta: Required<Nullable<ShortUrlMeta>>;
|
||||
tags: string[];
|
||||
domain: string | null;
|
||||
@@ -41,17 +48,18 @@ export interface ShortUrlMeta {
|
||||
maxVisits?: number;
|
||||
}
|
||||
|
||||
export interface ShortUrlVisitsSummary {
|
||||
total: number;
|
||||
nonBots: number;
|
||||
bots: number;
|
||||
}
|
||||
|
||||
export interface ShortUrlModalProps {
|
||||
shortUrl: ShortUrl;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export interface ShortUrlIdentifier {
|
||||
shortCode: string;
|
||||
domain: OptionalString;
|
||||
}
|
||||
|
||||
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
||||
dateCreated: 'Created at',
|
||||
shortCode: 'Short URL',
|
||||
@@ -72,3 +80,9 @@ export interface ExportableShortUrl {
|
||||
tags: string;
|
||||
visits: number;
|
||||
}
|
||||
|
||||
export interface ShortUrlsFilter {
|
||||
excludeBots?: boolean;
|
||||
excludeMaxVisitsReached?: boolean;
|
||||
excludePastValidUntil?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isNil } from 'ramda';
|
||||
import { useEffect } from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { Tooltip } from 'reactstrap';
|
||||
@@ -11,15 +10,17 @@ import { Result } from '../../utils/Result';
|
||||
import './CreateShortUrlResult.scss';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
||||
export interface CreateShortUrlResultProps {
|
||||
creation: ShortUrlCreation;
|
||||
resetCreateShortUrl: () => void;
|
||||
canBeClosed?: boolean;
|
||||
}
|
||||
|
||||
export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
|
||||
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||
{ creation, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||
) => {
|
||||
const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle();
|
||||
const { error, saved } = creation;
|
||||
|
||||
useEffect(() => {
|
||||
resetCreateShortUrl();
|
||||
@@ -29,16 +30,16 @@ export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
|
||||
return (
|
||||
<Result type="error" className="mt-3">
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||
<ShlinkApiError errorData={creation.errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNil(result)) {
|
||||
if (!saved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { shortUrl } = result;
|
||||
const { shortUrl } = creation.result;
|
||||
|
||||
return (
|
||||
<Result type="success" className="mt-3">
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { identity, pipe } from 'ramda';
|
||||
import { pipe } from 'ramda';
|
||||
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
|
||||
import { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { isInvalidDeletionError } from '../../api/utils';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
deleteShortUrl: (shortCode: string, domain: OptionalString) => Promise<void>;
|
||||
deleteShortUrl: (shortUrl: ShortUrlIdentifier) => Promise<void>;
|
||||
shortUrlDeleted: (shortUrl: ShortUrlIdentifier) => void;
|
||||
resetDeleteShortUrl: () => void;
|
||||
}
|
||||
|
||||
export const DeleteShortUrlModal = (
|
||||
{ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }: DeleteShortUrlModalConnectProps,
|
||||
) => {
|
||||
const DELETION_PATTERN = 'delete';
|
||||
|
||||
export const DeleteShortUrlModal = ({
|
||||
shortUrl,
|
||||
toggle,
|
||||
isOpen,
|
||||
shortUrlDeletion,
|
||||
resetDeleteShortUrl,
|
||||
deleteShortUrl,
|
||||
shortUrlDeleted,
|
||||
}: DeleteShortUrlModalConnectProps) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
useEffect(() => resetDeleteShortUrl, []);
|
||||
|
||||
const { error, errorData } = shortUrlDeletion;
|
||||
const { loading, error, deleted, errorData } = shortUrlDeletion;
|
||||
const close = pipe(resetDeleteShortUrl, toggle);
|
||||
const handleDeleteUrl = handleEventPreventingDefault(() => {
|
||||
const { shortCode, domain } = shortUrl;
|
||||
|
||||
deleteShortUrl(shortCode, domain)
|
||||
.then(toggle)
|
||||
.catch(identity);
|
||||
});
|
||||
const handleDeleteUrl = handleEventPreventingDefault(() => deleteShortUrl(shortUrl).then(toggle));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<Modal isOpen={isOpen} toggle={close} centered onClosed={() => deleted && shortUrlDeleted(shortUrl)}>
|
||||
<form onSubmit={handleDeleteUrl}>
|
||||
<ModalHeader toggle={close}>
|
||||
<span className="text-danger">Delete short URL</span>
|
||||
@@ -40,12 +43,12 @@ export const DeleteShortUrlModal = (
|
||||
<ModalBody>
|
||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||
<p>Write <b>{shortUrl.shortCode}</b> to confirm deletion.</p>
|
||||
<p>Write <b>{DELETION_PATTERN}</b> to confirm deletion.</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={`Insert the short code (${shortUrl.shortCode})`}
|
||||
placeholder={`Insert ${DELETION_PATTERN}`}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
@@ -61,9 +64,9 @@ export const DeleteShortUrlModal = (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
disabled={inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
|
||||
disabled={inputValue !== DELETION_PATTERN || loading}
|
||||
>
|
||||
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
|
||||
{loading ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ExportShortUrlsBtn = (
|
||||
longUrl: shortUrl.longUrl,
|
||||
title: shortUrl.title ?? '',
|
||||
tags: shortUrl.tags.join(','),
|
||||
visits: shortUrl.visitsCount,
|
||||
visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount,
|
||||
})));
|
||||
stopLoading();
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ import { ExternalLink } from 'react-external-link';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||
import { supportsNonRestCors, supportsQrErrorCorrection } from '../../utils/helpers/features';
|
||||
import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||
import { supportsNonRestCors } from '../../utils/helpers/features';
|
||||
import { ImageDownloader } from '../../common/services/ImageDownloader';
|
||||
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||
@@ -24,14 +24,10 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||
const [margin, setMargin] = useState(0);
|
||||
const [format, setFormat] = useState<QrCodeFormat>('png');
|
||||
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
|
||||
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
||||
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
|
||||
}), [selectedServer]);
|
||||
const displayDownloadBtn = supportsNonRestCors(selectedServer);
|
||||
const willRenderThreeControls = !capabilities.errorCorrectionIsSupported;
|
||||
const qrCodeUrl = useMemo(
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
|
||||
[shortUrl, size, format, margin, errorCorrection, capabilities],
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
|
||||
[shortUrl, size, format, margin, errorCorrection],
|
||||
);
|
||||
const totalSize = useMemo(() => size + margin, [size, margin]);
|
||||
const modalSize = useMemo(() => {
|
||||
@@ -49,7 +45,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row>
|
||||
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<label>Size: {size}px</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -61,7 +57,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<label htmlFor="marginControl">Margin: {margin}px</label>
|
||||
<input
|
||||
id="marginControl"
|
||||
@@ -74,14 +70,12 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||
</FormGroup>
|
||||
{capabilities.errorCorrectionIsSupported && (
|
||||
<FormGroup className="col-md-6">
|
||||
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormGroup className="col-md-6">
|
||||
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
|
||||
</FormGroup>
|
||||
</Row>
|
||||
<div className="text-center">
|
||||
<div className="mb-3">
|
||||
|
||||
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 {
|
||||
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 { SelectedServer } from '../../servers/data';
|
||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||
import './ShortUrlVisitsCount.scss';
|
||||
import { mutableRefToElementRef } from '../../utils/helpers/components';
|
||||
import { formatHumanFriendly, parseISO } from '../../utils/helpers/date';
|
||||
import './ShortUrlVisitsCount.scss';
|
||||
|
||||
interface ShortUrlVisitsCountProps {
|
||||
shortUrl?: ShortUrl | null;
|
||||
@@ -20,7 +21,8 @@ interface ShortUrlVisitsCountProps {
|
||||
export const ShortUrlVisitsCount = (
|
||||
{ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps,
|
||||
) => {
|
||||
const maxVisits = shortUrl?.meta?.maxVisits;
|
||||
const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {};
|
||||
const hasLimit = !!maxVisits || !!validSince || !!validUntil;
|
||||
const visitsLink = (
|
||||
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<strong
|
||||
@@ -31,29 +33,43 @@ export const ShortUrlVisitsCount = (
|
||||
</ShortUrlDetailLink>
|
||||
);
|
||||
|
||||
if (!maxVisits) {
|
||||
if (!hasLimit) {
|
||||
return visitsLink;
|
||||
}
|
||||
|
||||
const prettifiedMaxVisits = prettify(maxVisits);
|
||||
const tooltipRef = useRef<HTMLElement | undefined>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="indivisible">
|
||||
{visitsLink}
|
||||
<small
|
||||
className="short-urls-visits-count__max-visits-control"
|
||||
ref={mutableRefToElementRef(tooltipRef)}
|
||||
>
|
||||
{' '}/ {prettifiedMaxVisits}{' '}
|
||||
<sup>
|
||||
<small className="short-urls-visits-count__max-visits-control" ref={mutableRefToElementRef(tooltipRef)}>
|
||||
{maxVisits && <> / {prettify(maxVisits)}</>}
|
||||
<sup className="ms-1">
|
||||
<FontAwesomeIcon icon={infoIcon} />
|
||||
</sup>
|
||||
</small>
|
||||
</span>
|
||||
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom">
|
||||
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
|
||||
<ul className="list-unstyled mb-0">
|
||||
{maxVisits && (
|
||||
<li className="short-url-visits-count__tooltip-list-item">
|
||||
This short URL will not accept more than <b>{prettify(maxVisits)}</b> visit{maxVisits === 1 ? '' : 's'}.
|
||||
</li>
|
||||
)}
|
||||
{validSince && (
|
||||
<li className="short-url-visits-count__tooltip-list-item">
|
||||
This short URL will not accept visits
|
||||
before <b className="indivisible">{formatHumanFriendly(parseISO(validSince))}</b>.
|
||||
</li>
|
||||
)}
|
||||
{validUntil && (
|
||||
<li className="short-url-visits-count__tooltip-list-item">
|
||||
This short URL will not accept visits
|
||||
after <b className="indivisible">{formatHumanFriendly(parseISO(validUntil))}</b>.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
@import '../../utils/base';
|
||||
@import '../../utils/mixins/text-ellipsis';
|
||||
@import '../../utils/mixins/vertical-align';
|
||||
|
||||
.short-urls-row__cell.short-urls-row__cell {
|
||||
@@ -9,8 +10,24 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.short-urls-row__cell--relative {
|
||||
position: relative;
|
||||
.short-urls-row__cell--indivisible {
|
||||
@media (min-width: $lgMin) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__short-url-wrapper {
|
||||
@media (max-width: $mdMax) {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (min-width: $lgMin) {
|
||||
@include text-ellipsis();
|
||||
|
||||
vertical-align: bottom;
|
||||
display: inline-block;
|
||||
max-width: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__copy-hint {
|
||||
|
||||
@@ -1,54 +1,47 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { isEmpty } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import { TimeoutToggle } from '../../utils/helpers/hooks';
|
||||
import { Tag } from '../../tags/helpers/Tag';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { ShortUrl } from '../data';
|
||||
import { Time } from '../../utils/Time';
|
||||
import { Time } from '../../utils/dates/Time';
|
||||
import { Settings } from '../../settings/reducers/settings';
|
||||
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
|
||||
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
||||
import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu';
|
||||
import { Tags } from './Tags';
|
||||
import { ShortUrlStatus } from './ShortUrlStatus';
|
||||
import { useShortUrlsQuery } from './hooks';
|
||||
import './ShortUrlsRow.scss';
|
||||
|
||||
export interface ShortUrlsRowProps {
|
||||
interface ShortUrlsRowProps {
|
||||
onTagClick?: (tag: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrl: ShortUrl;
|
||||
}
|
||||
|
||||
interface ShortUrlsRowConnectProps extends ShortUrlsRowProps {
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export type ShortUrlsRowType = FC<ShortUrlsRowProps>;
|
||||
|
||||
export const ShortUrlsRow = (
|
||||
ShortUrlsRowMenu: FC<ShortUrlsRowMenuProps>,
|
||||
ShortUrlsRowMenu: ShortUrlsRowMenuType,
|
||||
colorGenerator: ColorGenerator,
|
||||
useTimeoutToggle: TimeoutToggle,
|
||||
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
|
||||
) => ({ shortUrl, selectedServer, onTagClick, settings }: ShortUrlsRowConnectProps) => {
|
||||
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
|
||||
const [active, setActive] = useTimeoutToggle(false, 500);
|
||||
const isFirstRun = useRef(true);
|
||||
|
||||
const renderTags = (tags: string[]) => {
|
||||
if (isEmpty(tags)) {
|
||||
return <i className="indivisible"><small>No tags</small></i>;
|
||||
}
|
||||
|
||||
return tags.map((tag) => (
|
||||
<Tag
|
||||
colorGenerator={colorGenerator}
|
||||
key={tag}
|
||||
text={tag}
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
const [{ excludeBots }] = useShortUrlsQuery();
|
||||
const { visits } = settings;
|
||||
const doExcludeBots = excludeBots ?? visits?.excludeBots;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRun.current) {
|
||||
isFirstRun.current = false;
|
||||
} else {
|
||||
setActive();
|
||||
}
|
||||
}, [shortUrl.visitsCount]);
|
||||
!isFirstRun.current && setActive();
|
||||
isFirstRun.current = false;
|
||||
}, [shortUrl.visitsSummary?.total, shortUrl.visitsSummary?.nonBots, shortUrl.visitsCount]);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
@@ -56,15 +49,20 @@ export const ShortUrlsRow = (
|
||||
<Time date={shortUrl.dateCreated} />
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||
<span className="indivisible short-urls-row__cell--relative">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
<span className="position-relative short-urls-row__cell--indivisible">
|
||||
<span className="short-urls-row__short-url-wrapper">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
</span>
|
||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||
<span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||
Copied short URL!
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}>
|
||||
<td
|
||||
className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break"
|
||||
data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}
|
||||
>
|
||||
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||
</td>
|
||||
{shortUrl.title && (
|
||||
@@ -72,15 +70,22 @@ export const ShortUrlsRow = (
|
||||
<ExternalLink href={shortUrl.longUrl} />
|
||||
</td>
|
||||
)}
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">
|
||||
<Tags tags={shortUrl.tags} colorGenerator={colorGenerator} onTagClick={onTagClick} />
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
|
||||
<ShortUrlVisitsCount
|
||||
visitsCount={shortUrl.visitsCount}
|
||||
visitsCount={(
|
||||
doExcludeBots ? shortUrl.visitsSummary?.nonBots : shortUrl.visitsSummary?.total
|
||||
) ?? shortUrl.visitsCount}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
active={active}
|
||||
/>
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Status">
|
||||
<ShortUrlStatus shortUrl={shortUrl} />
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell">
|
||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||
</td>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SelectedServer } from '../../servers/data';
|
||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||
|
||||
export interface ShortUrlsRowMenuProps {
|
||||
interface ShortUrlsRowMenuProps {
|
||||
selectedServer: SelectedServer;
|
||||
shortUrl: ShortUrl;
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export const ShortUrlsRowMenu = (
|
||||
QrCodeModal: ShortUrlModal,
|
||||
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
|
||||
const [isOpen, toggle] = useToggle();
|
||||
const [isQrModalOpen, toggleQrCode] = useToggle();
|
||||
const [isDeleteModalOpen, toggleDelete] = useToggle();
|
||||
const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle();
|
||||
const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle();
|
||||
|
||||
return (
|
||||
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||
@@ -37,17 +37,19 @@ export const ShortUrlsRowMenu = (
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<DropdownItem onClick={openQrCodeModal}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={closeQrCodeModal} />
|
||||
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem className="dropdown-item--danger" onClick={toggleDelete}>
|
||||
<DropdownItem className="dropdown-item--danger" onClick={openDeleteModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={closeDeleteModal} />
|
||||
</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,8 +5,7 @@ import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||
import { TagsFilteringMode } from '../../api/types';
|
||||
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||
import { BooleanString, parseOptionalBooleanToString } from '../../utils/utils';
|
||||
|
||||
interface ShortUrlsQueryCommon {
|
||||
search?: string;
|
||||
@@ -18,42 +17,60 @@ interface ShortUrlsQueryCommon {
|
||||
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
||||
orderBy?: string;
|
||||
tags?: string;
|
||||
excludeBots?: BooleanString;
|
||||
excludeMaxVisitsReached?: BooleanString;
|
||||
excludePastValidUntil?: BooleanString;
|
||||
}
|
||||
|
||||
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||
orderBy?: ShortUrlsOrder;
|
||||
tags: string[];
|
||||
excludeBots?: boolean;
|
||||
excludeMaxVisitsReached?: boolean;
|
||||
excludePastValidUntil?: boolean;
|
||||
}
|
||||
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||
|
||||
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams<{ serverId: string }>();
|
||||
const { search } = useLocation();
|
||||
const { serverId = '' } = useParams<{ serverId: string }>();
|
||||
|
||||
const query = useMemo(
|
||||
const filtering = useMemo(
|
||||
pipe(
|
||||
() => parseQuery<ShortUrlsQuery>(location.search),
|
||||
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
||||
() => parseQuery<ShortUrlsQuery>(search),
|
||||
({ orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...rest }): ShortUrlsFiltering => {
|
||||
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||
const parsedTags = tags?.split(',') ?? [];
|
||||
|
||||
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
||||
return {
|
||||
...rest,
|
||||
orderBy: parsedOrderBy,
|
||||
tags: parsedTags,
|
||||
excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined,
|
||||
excludeMaxVisitsReached: excludeMaxVisitsReached !== undefined ? excludeMaxVisitsReached === 'true' : undefined,
|
||||
excludePastValidUntil: excludePastValidUntil !== undefined ? excludePastValidUntil === 'true' : undefined,
|
||||
};
|
||||
},
|
||||
),
|
||||
[location.search],
|
||||
[search],
|
||||
);
|
||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
|
||||
const normalizedQuery: ShortUrlsQuery = {
|
||||
...mergedQuery,
|
||||
const merged = { ...filtering, ...extra };
|
||||
const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged;
|
||||
const query: ShortUrlsQuery = {
|
||||
...mergedFiltering,
|
||||
orderBy: orderBy && orderToString(orderBy),
|
||||
tags: tags.length > 0 ? tags.join(',') : undefined,
|
||||
excludeBots: parseOptionalBooleanToString(excludeBots),
|
||||
excludeMaxVisitsReached: parseOptionalBooleanToString(excludeMaxVisitsReached),
|
||||
excludePastValidUntil: parseOptionalBooleanToString(excludePastValidUntil),
|
||||
};
|
||||
const evolvedQuery = stringifyQuery(normalizedQuery);
|
||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||
const stringifiedQuery = stringifyQuery(query);
|
||||
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||
|
||||
navigate(`/server/${params.serverId ?? ''}/list-short-urls/1${queryString}`);
|
||||
navigate(`/server/${serverId}/list-short-urls/1${queryString}`);
|
||||
};
|
||||
|
||||
return [query, toFirstPageWithExtra];
|
||||
return [filtering, toFirstPageWithExtra];
|
||||
};
|
||||
|
||||
@@ -1,57 +1,72 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { ShortUrl, ShortUrlData } from '../data';
|
||||
import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
|
||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
||||
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
||||
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlCreation';
|
||||
|
||||
export interface ShortUrlCreation {
|
||||
result: ShortUrl | null;
|
||||
saving: boolean;
|
||||
error: boolean;
|
||||
export type ShortUrlCreation = {
|
||||
saving: false;
|
||||
saved: false;
|
||||
error: false;
|
||||
} | {
|
||||
saving: true;
|
||||
saved: false;
|
||||
error: false;
|
||||
} | {
|
||||
saving: false;
|
||||
saved: false;
|
||||
error: true;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface CreateShortUrlAction extends Action<string> {
|
||||
} | {
|
||||
result: ShortUrl;
|
||||
}
|
||||
saving: false;
|
||||
saved: true;
|
||||
error: false;
|
||||
};
|
||||
|
||||
export type CreateShortUrlAction = PayloadAction<ShortUrl>;
|
||||
|
||||
const initialState: ShortUrlCreation = {
|
||||
result: null,
|
||||
saving: false,
|
||||
saved: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & ApiErrorAction>({
|
||||
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
||||
[RESET_CREATE_SHORT_URL]: () => initialState,
|
||||
}, initialState);
|
||||
export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/createShortUrl`,
|
||||
(data: ShortUrlData, { getState }): Promise<ShortUrl> => {
|
||||
const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState);
|
||||
return shlinkCreateShortUrl(data);
|
||||
},
|
||||
);
|
||||
|
||||
export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (data: ShortUrlData) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
dispatch({ type: CREATE_SHORT_URL_START });
|
||||
const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState);
|
||||
export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType<typeof createShortUrl>) => {
|
||||
const { reducer, actions } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState: initialState as ShortUrlCreation, // Without this casting it infers type ShortUrlCreationWaiting
|
||||
reducers: {
|
||||
resetCreateShortUrl: () => initialState,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(createShortUrlThunk.pending, () => ({ saving: true, saved: false, error: false }));
|
||||
builder.addCase(
|
||||
createShortUrlThunk.rejected,
|
||||
(_, { error }) => ({ saving: false, saved: false, error: true, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(
|
||||
createShortUrlThunk.fulfilled,
|
||||
(_, { payload: result }) => ({ result, saving: false, saved: true, error: false }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await shlinkCreateShortUrl(data);
|
||||
const { resetCreateShortUrl } = actions;
|
||||
|
||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
reducer,
|
||||
resetCreateShortUrl,
|
||||
};
|
||||
};
|
||||
|
||||
export const resetCreateShortUrl = buildActionCreator(RESET_CREATE_SHORT_URL);
|
||||
|
||||
@@ -1,56 +1,60 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
import { ShortUrl, ShortUrlIdentifier } from '../data';
|
||||
|
||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
|
||||
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
|
||||
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlDeletion';
|
||||
|
||||
export interface ShortUrlDeletion {
|
||||
shortCode: string;
|
||||
loading: boolean;
|
||||
deleted: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface DeleteShortUrlAction extends Action<string> {
|
||||
shortCode: string;
|
||||
domain?: string | null;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlDeletion = {
|
||||
shortCode: '',
|
||||
loading: false,
|
||||
deleted: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & ApiErrorAction>({
|
||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||
[RESET_DELETE_SHORT_URL]: () => initialState,
|
||||
}, initialState);
|
||||
|
||||
export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
shortCode: string,
|
||||
domain?: string | null,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: DELETE_SHORT_URL_START });
|
||||
const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/deleteShortUrl`,
|
||||
async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise<ShortUrlIdentifier> => {
|
||||
const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState);
|
||||
await shlinkDeleteShortUrl(shortCode, domain);
|
||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
return { shortCode, domain };
|
||||
},
|
||||
);
|
||||
|
||||
throw e;
|
||||
}
|
||||
export const shortUrlDeleted = createAction<ShortUrl>(`${REDUCER_PREFIX}/shortUrlDeleted`);
|
||||
|
||||
export const shortUrlDeletionReducerCreator = (deleteShortUrlThunk: ReturnType<typeof deleteShortUrl>) => {
|
||||
const { actions, reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {
|
||||
resetDeleteShortUrl: () => initialState,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(
|
||||
deleteShortUrlThunk.pending,
|
||||
(state) => ({ ...state, loading: true, error: false, deleted: false }),
|
||||
);
|
||||
builder.addCase(deleteShortUrlThunk.rejected, (state, { error }) => (
|
||||
{ ...state, errorData: parseApiError(error), loading: false, error: true, deleted: false }
|
||||
));
|
||||
builder.addCase(deleteShortUrlThunk.fulfilled, (state, { payload }) => (
|
||||
{ ...state, shortCode: payload.shortCode, loading: false, error: false, deleted: true }
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
const { resetDeleteShortUrl } = actions;
|
||||
|
||||
return { reducer, resetDeleteShortUrl };
|
||||
};
|
||||
|
||||
export const resetDeleteShortUrl = buildActionCreator(RESET_DELETE_SHORT_URL);
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShortUrl } from '../data';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { ShortUrl, ShortUrlIdentifier } from '../data';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { GetState } from '../../container/types';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
|
||||
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
||||
export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR';
|
||||
export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL';
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlDetail';
|
||||
|
||||
export interface ShortUrlDetail {
|
||||
shortUrl?: ShortUrl;
|
||||
@@ -20,35 +15,36 @@ export interface ShortUrlDetail {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ShortUrlDetailAction extends Action<string> {
|
||||
shortUrl: ShortUrl;
|
||||
}
|
||||
export type ShortUrlDetailAction = PayloadAction<ShortUrl>;
|
||||
|
||||
const initialState: ShortUrlDetail = {
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ApiErrorAction>({
|
||||
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
||||
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
||||
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
||||
}, initialState);
|
||||
export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
||||
const getShortUrlDetail = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/getShortUrlDetail`,
|
||||
async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise<ShortUrl> => {
|
||||
const { shortUrlsList } = getState();
|
||||
const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain));
|
||||
|
||||
export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
shortCode: string,
|
||||
domain: OptionalString,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: GET_SHORT_URL_DETAIL_START });
|
||||
return alreadyLoaded ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const { shortUrlsList } = getState();
|
||||
const shortUrl = shortUrlsList?.shortUrls?.data.find(
|
||||
(url) => shortUrlMatches(url, shortCode, domain),
|
||||
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
||||
const { reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(getShortUrlDetail.pending, () => ({ loading: true, error: false }));
|
||||
builder.addCase(getShortUrlDetail.rejected, (_, { error }) => (
|
||||
{ loading: false, error: true, errorData: parseApiError(error) }
|
||||
));
|
||||
builder.addCase(getShortUrlDetail.fulfilled, (_, { payload: shortUrl }) => ({ ...initialState, shortUrl }));
|
||||
},
|
||||
});
|
||||
|
||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
return { reducer, getShortUrlDetail };
|
||||
};
|
||||
|
||||
@@ -1,55 +1,53 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { EditShortUrlData, ShortUrl } from '../data';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { EditShortUrlData, ShortUrl, ShortUrlIdentifier } from '../data';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
|
||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
|
||||
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlEdition';
|
||||
|
||||
export interface ShortUrlEdition {
|
||||
shortUrl?: ShortUrl;
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ShortUrlEditedAction extends Action<string> {
|
||||
shortUrl: ShortUrl;
|
||||
export interface EditShortUrl extends ShortUrlIdentifier {
|
||||
data: EditShortUrlData;
|
||||
}
|
||||
|
||||
export type ShortUrlEditedAction = PayloadAction<ShortUrl>;
|
||||
|
||||
const initialState: ShortUrlEdition = {
|
||||
saving: false,
|
||||
saved: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ApiErrorAction>({
|
||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
||||
}, initialState);
|
||||
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/editShortUrl`,
|
||||
({ shortCode, domain, data }: EditShortUrl, { getState }): Promise<ShortUrl> => {
|
||||
const { updateShortUrl } = buildShlinkApiClient(getState);
|
||||
return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates
|
||||
},
|
||||
);
|
||||
|
||||
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
shortCode: string,
|
||||
domain: OptionalString,
|
||||
data: EditShortUrlData,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_START });
|
||||
|
||||
const { updateShortUrl } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const shortUrl = await updateShortUrl(shortCode, domain, data as any); // FIXME parse dates;
|
||||
|
||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType<typeof editShortUrl>) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(editShortUrlThunk.pending, (state) => ({ ...state, saving: true, error: false, saved: false }));
|
||||
builder.addCase(
|
||||
editShortUrlThunk.rejected,
|
||||
(state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(
|
||||
editShortUrlThunk.fulfilled,
|
||||
(_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { assoc, assocPath, last, pipe, reject } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { assocPath, last, pipe, reject } from 'ramda';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
||||
|
||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
||||
export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
|
||||
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
||||
import { shortUrlDeleted } from './shortUrlDeletion';
|
||||
import { createShortUrl } from './shortUrlCreation';
|
||||
import { editShortUrl } from './shortUrlEdition';
|
||||
import { ShortUrl } from '../data';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlsList';
|
||||
export const ITEMS_IN_OVERVIEW_PAGE = 5;
|
||||
|
||||
export interface ShortUrlsList {
|
||||
@@ -22,94 +19,97 @@ export interface ShortUrlsList {
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface ListShortUrlsAction extends Action<string> {
|
||||
shortUrls: ShlinkShortUrlsResponse;
|
||||
}
|
||||
|
||||
export type ListShortUrlsCombinedAction = (
|
||||
ListShortUrlsAction
|
||||
& CreateVisitsAction
|
||||
& CreateShortUrlAction
|
||||
& DeleteShortUrlAction
|
||||
& ShortUrlEditedAction
|
||||
);
|
||||
|
||||
const initialState: ShortUrlsList = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
||||
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
|
||||
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||
[SHORT_URL_DELETED]: pipe(
|
||||
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
|
||||
state,
|
||||
)),
|
||||
(state) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'pagination', 'totalItems'],
|
||||
state.shortUrls.pagination.totalItems - 1,
|
||||
state,
|
||||
)),
|
||||
),
|
||||
[CREATE_VISITS]: (state, { createdVisits }) => assocPath(
|
||||
['shortUrls', 'data'],
|
||||
state.shortUrls?.data?.map(
|
||||
(currentShortUrl) => {
|
||||
// Find the last of the new visit for this short URL, and pick the amount of visits from it
|
||||
const lastVisit = last(
|
||||
createdVisits.filter(
|
||||
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
|
||||
),
|
||||
);
|
||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/listShortUrls`,
|
||||
(params: ShlinkShortUrlsListParams | void, { getState }): Promise<ShlinkShortUrlsResponse> => {
|
||||
const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState);
|
||||
return shlinkListShortUrls(params ?? {});
|
||||
},
|
||||
);
|
||||
|
||||
return lastVisit?.shortUrl
|
||||
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
|
||||
: currentShortUrl;
|
||||
},
|
||||
),
|
||||
state,
|
||||
),
|
||||
[CREATE_SHORT_URL]: pipe(
|
||||
// The only place where the list and the creation form coexist is the overview page.
|
||||
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
|
||||
// We can also remove the items above the amount that is displayed there.
|
||||
(state: ShortUrlsList, { result }: CreateShortUrlAction) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
[result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)],
|
||||
state,
|
||||
)),
|
||||
(state: ShortUrlsList) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'pagination', 'totalItems'],
|
||||
state.shortUrls.pagination.totalItems + 1,
|
||||
state,
|
||||
)),
|
||||
),
|
||||
[SHORT_URL_EDITED]: (state, { shortUrl: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
state.shortUrls.data.map((shortUrl) => {
|
||||
const { shortCode, domain } = editedShortUrl;
|
||||
export const shortUrlsListReducerCreator = (
|
||||
listShortUrlsThunk: ReturnType<typeof listShortUrls>,
|
||||
editShortUrlThunk: ReturnType<typeof editShortUrl>,
|
||||
createShortUrlThunk: ReturnType<typeof createShortUrl>,
|
||||
) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(listShortUrlsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||
builder.addCase(listShortUrlsThunk.rejected, () => ({ loading: false, error: true }));
|
||||
builder.addCase(
|
||||
listShortUrlsThunk.fulfilled,
|
||||
(_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||
);
|
||||
|
||||
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
|
||||
}),
|
||||
state,
|
||||
)),
|
||||
}, initialState);
|
||||
builder.addCase(
|
||||
createShortUrlThunk.fulfilled,
|
||||
pipe(
|
||||
// The only place where the list and the creation form coexist is the overview page.
|
||||
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
|
||||
// We can also remove the items above the amount that is displayed there.
|
||||
(state, { payload }) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
[payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)],
|
||||
state,
|
||||
)),
|
||||
(state: ShortUrlsList) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'pagination', 'totalItems'],
|
||||
state.shortUrls.pagination.totalItems + 1,
|
||||
state,
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
params: ShlinkShortUrlsListParams = {},
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: LIST_SHORT_URLS_START });
|
||||
const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState);
|
||||
builder.addCase(
|
||||
editShortUrlThunk.fulfilled,
|
||||
(state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
state.shortUrls.data.map((shortUrl) => {
|
||||
const { shortCode, domain } = editedShortUrl;
|
||||
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
|
||||
}),
|
||||
state,
|
||||
)),
|
||||
);
|
||||
|
||||
try {
|
||||
const shortUrls = await shlinkListShortUrls(params);
|
||||
builder.addCase(
|
||||
shortUrlDeleted,
|
||||
pipe(
|
||||
(state, { payload }) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
reject<ShortUrl, ShortUrl[]>((shortUrl) =>
|
||||
shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data),
|
||||
state,
|
||||
)),
|
||||
(state) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'pagination', 'totalItems'],
|
||||
state.shortUrls.pagination.totalItems - 1,
|
||||
state,
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_SHORT_URLS_ERROR });
|
||||
}
|
||||
};
|
||||
builder.addCase(
|
||||
createNewVisits,
|
||||
(state, { payload }) => assocPath(
|
||||
['shortUrls', 'data'],
|
||||
state.shortUrls?.data?.map(
|
||||
// Find the last of the new visit for this short URL, and pick its short URL. It will have an up-to-date amount of visits.
|
||||
(currentShortUrl) => last(
|
||||
payload.createdVisits.filter(
|
||||
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
|
||||
),
|
||||
)?.shortUrl ?? currentShortUrl,
|
||||
),
|
||||
state,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import { prop } from 'ramda';
|
||||
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
|
||||
import { ShortUrlsList } from '../ShortUrlsList';
|
||||
import { ShortUrlsRow } from '../helpers/ShortUrlsRow';
|
||||
@@ -6,16 +7,16 @@ import { ShortUrlsRowMenu } from '../helpers/ShortUrlsRowMenu';
|
||||
import { CreateShortUrl } from '../CreateShortUrl';
|
||||
import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal';
|
||||
import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult';
|
||||
import { listShortUrls } from '../reducers/shortUrlsList';
|
||||
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
||||
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
||||
import { editShortUrl } from '../reducers/shortUrlEdition';
|
||||
import { listShortUrls, shortUrlsListReducerCreator } from '../reducers/shortUrlsList';
|
||||
import { shortUrlCreationReducerCreator, createShortUrl } from '../reducers/shortUrlCreation';
|
||||
import { shortUrlDeletionReducerCreator, deleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
|
||||
import { editShortUrl, shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition';
|
||||
import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { ShortUrlsTable } from '../ShortUrlsTable';
|
||||
import { QrCodeModal } from '../helpers/QrCodeModal';
|
||||
import { ShortUrlForm } from '../ShortUrlForm';
|
||||
import { EditShortUrl } from '../EditShortUrl';
|
||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||
import { QrCodeModal } from '../helpers/QrCodeModal';
|
||||
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
@@ -27,7 +28,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
|
||||
bottle.decorator('ShortUrlsRow', connect(['settings']));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
|
||||
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector');
|
||||
@@ -35,7 +39,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
|
||||
bottle.decorator(
|
||||
'CreateShortUrl',
|
||||
connect(['shortUrlCreationResult', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
|
||||
connect(['shortUrlCreation', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
|
||||
);
|
||||
|
||||
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
|
||||
@@ -45,7 +49,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
));
|
||||
|
||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||
bottle.decorator('DeleteShortUrlModal', connect(['shortUrlDeletion'], ['deleteShortUrl', 'resetDeleteShortUrl']));
|
||||
bottle.decorator('DeleteShortUrlModal', connect(
|
||||
['shortUrlDeletion'],
|
||||
['deleteShortUrl', 'shortUrlDeleted', 'resetDeleteShortUrl'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
|
||||
bottle.decorator('QrCodeModal', connect(['selectedServer']));
|
||||
@@ -55,16 +62,39 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
|
||||
bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));
|
||||
|
||||
// Reducers
|
||||
bottle.serviceFactory(
|
||||
'shortUrlsListReducerCreator',
|
||||
shortUrlsListReducerCreator,
|
||||
'listShortUrls',
|
||||
'editShortUrl',
|
||||
'createShortUrl',
|
||||
);
|
||||
bottle.serviceFactory('shortUrlsListReducer', prop('reducer'), 'shortUrlsListReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl');
|
||||
bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'editShortUrl');
|
||||
bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'deleteShortUrl');
|
||||
bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('shortUrlDetailReducer', prop('reducer'), 'shortUrlDetailReducerCreator');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||
|
||||
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
|
||||
bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator');
|
||||
|
||||
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
|
||||
bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator');
|
||||
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
|
||||
|
||||
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator');
|
||||
|
||||
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
@@ -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 { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
|
||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import {
|
||||
TagsOrderableFields,
|
||||
TAGS_ORDERABLE_FIELDS,
|
||||
TagsListChildrenProps,
|
||||
TagsOrder,
|
||||
} from './data/TagsListChildrenProps';
|
||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||
import { TagsOrderableFields, TAGS_ORDERABLE_FIELDS, TagsOrder } from './data/TagsListChildrenProps';
|
||||
import { NormalizedTag } from './data';
|
||||
import { TagsTableProps } from './TagsTable';
|
||||
|
||||
@@ -30,10 +24,9 @@ export interface TagsListProps {
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||
export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||
) => {
|
||||
const [mode, setMode] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards');
|
||||
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
|
||||
const resolveSortedTags = pipe(
|
||||
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
||||
@@ -73,26 +66,21 @@ export const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<Tag
|
||||
|
||||
const sortedTags = resolveSortedTags();
|
||||
|
||||
return mode === 'cards'
|
||||
? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
|
||||
: (
|
||||
<TagsTable
|
||||
sortedTags={sortedTags}
|
||||
selectedServer={selectedServer}
|
||||
currentOrder={order}
|
||||
orderByColumn={orderByColumn}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TagsTable
|
||||
sortedTags={sortedTags}
|
||||
selectedServer={selectedServer}
|
||||
currentOrder={order}
|
||||
orderByColumn={orderByColumn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterTags} />
|
||||
<Row className="mb-3">
|
||||
<div className="col-lg-6">
|
||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
<div className="col-lg-6 mt-3 mt-lg-0">
|
||||
<div className="col-lg-6 offset-lg-6">
|
||||
<OrderingDropdown
|
||||
items={TAGS_ORDERABLE_FIELDS}
|
||||
order={order}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -13,15 +13,14 @@ interface DeleteTagConfirmModalProps extends TagModalProps {
|
||||
export const DeleteTagConfirmModal = (
|
||||
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
|
||||
) => {
|
||||
const { deleting, error, errorData } = tagDelete;
|
||||
const { deleting, error, deleted, errorData } = tagDelete;
|
||||
const doDelete = async () => {
|
||||
await deleteTag(tag);
|
||||
tagDeleted(tag);
|
||||
toggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggle={toggle} isOpen={isOpen} centered>
|
||||
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={() => deleted && tagDeleted(tag)}>
|
||||
<ModalHeader toggle={toggle} className="text-danger">Delete tag</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete tag <b>{tag}</b>?
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pipe } from 'ramda';
|
||||
import { useState } from 'react';
|
||||
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover, InputGroup } from 'reactstrap';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
@@ -7,15 +8,15 @@ import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import { TagModalProps } from '../data';
|
||||
import { TagEdition } from '../reducers/tagEdit';
|
||||
import { EditTag, TagEdition } from '../reducers/tagEdit';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
import './EditTagModal.scss';
|
||||
|
||||
interface EditTagModalProps extends TagModalProps {
|
||||
tagEdit: TagEdition;
|
||||
editTag: (oldName: string, newName: string, color: string) => Promise<void>;
|
||||
tagEdited: (oldName: string, newName: string, color: string) => void;
|
||||
editTag: (editTag: EditTag) => Promise<void>;
|
||||
tagEdited: (tagEdited: EditTag) => void;
|
||||
}
|
||||
|
||||
export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
@@ -24,16 +25,17 @@ export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
const [newTagName, setNewTagName] = useState(tag);
|
||||
const [color, setColor] = useState(getColorForKey(tag));
|
||||
const [showColorPicker, toggleColorPicker, , hideColorPicker] = useToggle();
|
||||
const { editing, error, errorData } = tagEdit;
|
||||
const { editing, error, edited, errorData } = tagEdit;
|
||||
const saveTag = handleEventPreventingDefault(
|
||||
async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}),
|
||||
async () => {
|
||||
await editTag({ oldName: tag, newName: newTagName, color });
|
||||
toggle();
|
||||
},
|
||||
);
|
||||
const onClosed = pipe(hideColorPicker, () => edited && tagEdited({ oldName: tag, newName: newTagName, color }));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||
<form name="editTag" onSubmit={saveTag}>
|
||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||
<ModalBody>
|
||||
|
||||
@@ -1,52 +1,45 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
|
||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
||||
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
||||
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
||||
const REDUCER_PREFIX = 'shlink/tagDelete';
|
||||
|
||||
export interface TagDeletion {
|
||||
deleting: boolean;
|
||||
deleted: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface DeleteTagAction extends Action<string> {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
const initialState: TagDeletion = {
|
||||
deleting: false,
|
||||
deleted: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<TagDeletion, ApiErrorAction>({
|
||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
||||
}, initialState);
|
||||
export const tagDeleted = createAction<string>(`${REDUCER_PREFIX}/tagDeleted`);
|
||||
|
||||
export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: string) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
dispatch({ type: DELETE_TAG_START });
|
||||
const { deleteTags } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
export const tagDeleteReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
||||
const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string, { getState }): Promise<void> => {
|
||||
const { deleteTags } = buildShlinkApiClient(getState);
|
||||
await deleteTags([tag]);
|
||||
dispatch({ type: DELETE_TAG });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
const { reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(deleteTag.pending, () => ({ deleting: true, deleted: false, error: false }));
|
||||
builder.addCase(
|
||||
deleteTag.rejected,
|
||||
(_, { error }) => ({ deleting: false, deleted: false, error: true, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(deleteTag.fulfilled, () => ({ deleting: false, deleted: true, error: false }));
|
||||
},
|
||||
});
|
||||
|
||||
return { reducer, deleteTag };
|
||||
};
|
||||
|
||||
export const tagDeleted = (tag: string): DeleteTagAction => ({ type: TAG_DELETED, tag });
|
||||
|
||||
@@ -1,72 +1,66 @@
|
||||
import { pick } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
|
||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
||||
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
||||
|
||||
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
||||
const REDUCER_PREFIX = 'shlink/tagEdit';
|
||||
|
||||
export interface TagEdition {
|
||||
oldName: string;
|
||||
newName: string;
|
||||
oldName?: string;
|
||||
newName?: string;
|
||||
editing: boolean;
|
||||
edited: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface EditTagAction extends Action<string> {
|
||||
export interface EditTag {
|
||||
oldName: string;
|
||||
newName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type EditTagAction = PayloadAction<EditTag>;
|
||||
|
||||
const initialState: TagEdition = {
|
||||
oldName: '',
|
||||
newName: '',
|
||||
editing: false,
|
||||
edited: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
|
||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
||||
[EDIT_TAG]: (_, action) => ({
|
||||
...pick(['oldName', 'newName'], action),
|
||||
editing: false,
|
||||
error: false,
|
||||
}),
|
||||
}, initialState);
|
||||
export const tagEdited = createAction<EditTag>(`${REDUCER_PREFIX}/tagEdited`);
|
||||
|
||||
export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGenerator: ColorGenerator) => (
|
||||
oldName: string,
|
||||
newName: string,
|
||||
color: string,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: EDIT_TAG_START });
|
||||
const { editTag: shlinkEditTag } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
await shlinkEditTag(oldName, newName);
|
||||
export const editTag = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
colorGenerator: ColorGenerator,
|
||||
) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/editTag`,
|
||||
async ({ oldName, newName, color }: EditTag, { getState }): Promise<EditTag> => {
|
||||
await buildShlinkApiClient(getState).editTag(oldName, newName);
|
||||
colorGenerator.setColorForKey(newName, color);
|
||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return { oldName, newName, color };
|
||||
},
|
||||
);
|
||||
|
||||
export const tagEdited = (oldName: string, newName: string, color: string): EditTagAction => ({
|
||||
type: TAG_EDITED,
|
||||
oldName,
|
||||
newName,
|
||||
color,
|
||||
export const tagEditReducerCreator = (editTagThunk: ReturnType<typeof editTag>) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(editTagThunk.pending, () => ({ editing: true, edited: false, error: false }));
|
||||
builder.addCase(
|
||||
editTagThunk.rejected,
|
||||
(_, { error }) => ({ editing: false, edited: false, error: true, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(editTagThunk.fulfilled, (_, { payload }) => ({
|
||||
...pick(['oldName', 'newName'], payload),
|
||||
editing: false,
|
||||
edited: true,
|
||||
error: false,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { isEmpty, reject } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ProblemDetailsError, ShlinkTags } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkTags } from '../../api/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { CreateVisit, Stats } from '../../visits/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { TagStats } from '../data';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from '../../short-urls/reducers/shortUrlCreation';
|
||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
||||
import { createShortUrl } from '../../short-urls/reducers/shortUrlCreation';
|
||||
import { tagDeleted } from './tagDelete';
|
||||
import { tagEdited } from './tagEdit';
|
||||
import { ProblemDetailsError } from '../../api/types/errors';
|
||||
|
||||
export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
|
||||
export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
|
||||
export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
|
||||
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
|
||||
const REDUCER_PREFIX = 'shlink/tagsList';
|
||||
|
||||
type TagsStatsMap = Record<string, TagStats>;
|
||||
|
||||
@@ -29,24 +25,12 @@ export interface TagsList {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
interface ListTagsAction extends Action<string> {
|
||||
interface ListTags {
|
||||
tags: string[];
|
||||
stats: TagsStatsMap;
|
||||
}
|
||||
|
||||
interface FilterTagsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
type TagsCombinedAction = ListTagsAction
|
||||
& DeleteTagAction
|
||||
& CreateVisitsAction
|
||||
& CreateShortUrlAction
|
||||
& EditTagAction
|
||||
& FilterTagsAction
|
||||
& ApiErrorAction;
|
||||
|
||||
const initialState = {
|
||||
const initialState: TagsList = {
|
||||
tags: [],
|
||||
filteredTags: [],
|
||||
stats: {},
|
||||
@@ -65,10 +49,13 @@ const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags
|
||||
|
||||
const tagStats = theStats[tag];
|
||||
|
||||
tagStats.visitsCount += increase;
|
||||
theStats[tag] = tagStats; // eslint-disable-line no-param-reassign
|
||||
|
||||
return theStats;
|
||||
return {
|
||||
...theStats,
|
||||
[tag]: {
|
||||
...tagStats,
|
||||
visitsCount: tagStats.visitsCount + increase,
|
||||
},
|
||||
};
|
||||
}, { ...stats });
|
||||
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
||||
createdVisits.reduce<Stats>((acc, { shortUrl }) => {
|
||||
@@ -80,47 +67,15 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
|
||||
}, {}),
|
||||
);
|
||||
|
||||
export default buildReducer<TagsList, TagsCombinedAction>({
|
||||
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
||||
[TAG_DELETED]: (state, { tag }) => ({
|
||||
...state,
|
||||
tags: rejectTag(state.tags, tag),
|
||||
filteredTags: rejectTag(state.filteredTags, tag),
|
||||
}),
|
||||
[TAG_EDITED]: (state, { oldName, newName }) => ({
|
||||
...state,
|
||||
tags: state.tags.map(renameTag(oldName, newName)).sort(),
|
||||
filteredTags: state.filteredTags.map(renameTag(oldName, newName)).sort(),
|
||||
}),
|
||||
[FILTER_TAGS]: (state, { searchTerm }) => ({
|
||||
...state,
|
||||
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())),
|
||||
}),
|
||||
[CREATE_VISITS]: (state, { createdVisits }) => ({
|
||||
...state,
|
||||
stats: increaseVisitsForTags(calculateVisitsPerTag(createdVisits), state.stats),
|
||||
}),
|
||||
[CREATE_SHORT_URL]: ({ tags: stateTags, ...rest }, { result }) => ({
|
||||
...rest,
|
||||
tags: stateTags.concat(result.tags.filter((tag) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
|
||||
}),
|
||||
}, initialState);
|
||||
export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/listTags`,
|
||||
async (_: void, { getState }): Promise<ListTags> => {
|
||||
const { tagsList } = getState();
|
||||
|
||||
export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => () => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
const { tagsList } = getState();
|
||||
if (!force && !isEmpty(tagsList.tags)) {
|
||||
return tagsList;
|
||||
}
|
||||
|
||||
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: LIST_TAGS_START });
|
||||
|
||||
try {
|
||||
const { listTags: shlinkListTags } = buildShlinkApiClient(getState);
|
||||
const { tags, stats = [] }: ShlinkTags = await shlinkListTags();
|
||||
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => {
|
||||
@@ -129,10 +84,55 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
return { tags, stats: processedStats };
|
||||
},
|
||||
);
|
||||
|
||||
export const filterTags = (searchTerm: string): FilterTagsAction => ({ type: FILTER_TAGS, searchTerm });
|
||||
export const filterTags = createAction<string>(`${REDUCER_PREFIX}/filterTags`);
|
||||
|
||||
export const tagsListReducerCreator = (
|
||||
listTagsThunk: ReturnType<typeof listTags>,
|
||||
createShortUrlThunk: ReturnType<typeof createShortUrl>,
|
||||
) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(filterTags, (state, { payload: searchTerm }) => ({
|
||||
...state,
|
||||
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())),
|
||||
}));
|
||||
|
||||
builder.addCase(listTagsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||
builder.addCase(listTagsThunk.rejected, (_, { error }) => (
|
||||
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||
));
|
||||
builder.addCase(listTagsThunk.fulfilled, (_, { payload }) => (
|
||||
{ ...initialState, stats: payload.stats, tags: payload.tags, filteredTags: payload.tags }
|
||||
));
|
||||
|
||||
builder.addCase(tagDeleted, ({ tags, filteredTags, ...rest }, { payload: tag }) => ({
|
||||
...rest,
|
||||
tags: rejectTag(tags, tag),
|
||||
filteredTags: rejectTag(filteredTags, tag),
|
||||
}));
|
||||
builder.addCase(tagEdited, ({ tags, filteredTags, stats, ...rest }, { payload }) => ({
|
||||
...rest,
|
||||
stats: {
|
||||
...stats,
|
||||
[payload.newName]: stats[payload.oldName],
|
||||
},
|
||||
tags: tags.map(renameTag(payload.oldName, payload.newName)).sort(),
|
||||
filteredTags: filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(),
|
||||
}));
|
||||
builder.addCase(createNewVisits, (state, { payload }) => ({
|
||||
...state,
|
||||
stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats),
|
||||
}));
|
||||
|
||||
builder.addCase(createShortUrlThunk.fulfilled, ({ tags: stateTags, ...rest }, { payload }) => ({
|
||||
...rest,
|
||||
tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user