Compare commits

..

81 Commits

Author SHA1 Message Date
Alejandro Celaya
ff1821666e Merge pull request #533 from shlinkio/develop
Release 3.4.2
2021-12-07 20:52:41 +01:00
Alejandro Celaya
9c6c1b43c8 Merge pull request #532 from acelaya-forks/feature/invalid-selector-fix
Feature/invalid selector fix
2021-12-07 20:48:11 +01:00
Alejandro Celaya
4986dbcb91 Fixed tests 2021-12-07 20:41:07 +01:00
Alejandro Celaya
527d4acf17 Updated changelog 2021-12-07 20:32:48 +01:00
Alejandro Celaya
0237253caf Fixed crash in domains when using a domain with port as element ID 2021-12-07 20:24:59 +01:00
Alejandro Celaya
47f5f47867 Merge pull request #529 from shlinkio/develop
Release 3.4.1
2021-11-20 10:11:57 +01:00
Alejandro Celaya
70d4572797 Merge pull request #528 from acelaya-forks/feature/adr
Added first Architecture Decision Records
2021-11-20 10:07:29 +01:00
Alejandro Celaya
8bfa14386b Added first Architecture Decision Records 2021-11-20 10:06:43 +01:00
Alejandro Celaya
9f6401c30b Merge pull request #527 from acelaya-forks/feature/omit-auto-connect-on-export
Fixed export servers to ensure autoConnect is not included
2021-11-20 09:50:37 +01:00
Alejandro Celaya
14b2ee53b5 Fixed export servers to ensure autoConnect is not included 2021-11-20 09:44:12 +01:00
Alejandro Celaya
7db9974e8d Merge pull request #523 from acelaya-forks/feature/home-alignment
Updated landing page to be vertically aligned on mobile devices
2021-11-13 23:08:44 +01:00
Alejandro Celaya
7d29129ca1 Updated landing page to be vertically aligned on mobile devices 2021-11-13 23:04:59 +01:00
Alejandro Celaya
42152c6872 Merge pull request #519 from shlinkio/develop
Release 3.4.0
2021-11-11 21:44:39 +01:00
Alejandro Celaya
b7e9afd54a Added v3.4.0 to changelog 2021-11-11 21:42:58 +01:00
Alejandro Celaya
3bc9bd2ef8 Merge pull request #517 from acelaya-forks/feature/reset-page
Feature/reset page
2021-11-11 21:42:27 +01:00
Alejandro Celaya
7bc3819ebe Fixed TS error in SearchBar test 2021-11-11 21:38:37 +01:00
Alejandro Celaya
0642443aa9 Updated changelog 2021-11-11 21:32:28 +01:00
Alejandro Celaya
2e77cd1969 Removed handling of most short URLs list params from a reducer 2021-11-11 21:28:17 +01:00
Alejandro Celaya
21b8e05e35 Moved dates handling in short URLs list to query 2021-11-10 22:25:56 +01:00
Alejandro Celaya
ed038b9799 Fixed ShortUrlsList test 2021-11-08 23:41:17 +01:00
Alejandro Celaya
5f33059de1 Improved SearchBar test 2021-11-08 23:23:45 +01:00
Alejandro Celaya
3bc5b4c154 Extended ShortUrlsPaginator so that it allows appending current query string 2021-11-08 22:13:37 +01:00
Alejandro Celaya
a2421ee2d3 Created helper function to evolve a query string based on an object 2021-11-07 11:22:29 +01:00
Alejandro Celaya
109baef828 Minor changes on tags filtering for short URLs 2021-11-07 11:03:31 +01:00
Alejandro Celaya
303900756d Added TableOrderIcon test 2021-11-06 22:46:40 +01:00
Alejandro Celaya
fe81e023e8 Moved table sorting icon to its own component wrapping the logic 2021-11-06 22:34:29 +01:00
Alejandro Celaya
5906921eec Merge pull request #516 from acelaya-forks/feature/consistent-tags-sorting
Feature/consistent tags sorting
2021-11-06 12:36:19 +01:00
Alejandro Celaya
ee826458be Updated sorting dropdown to accept an order object instead of two individual props 2021-11-06 12:26:20 +01:00
Alejandro Celaya
7169c6e083 Updated changelog 2021-11-06 12:05:49 +01:00
Alejandro Celaya
0bb5c7d8af Simplified branches while resolving server Id 2021-11-06 12:04:26 +01:00
Alejandro Celaya
a6892b8a12 Covered ordering use cases on TagsList test 2021-11-06 11:58:59 +01:00
Alejandro Celaya
765c4713a2 Fixed all tests to work with new tags sorting approach 2021-11-06 11:30:42 +01:00
Alejandro Celaya
e6737ff1f2 Moved logic to render sorting icon to tags list, as it's too specific 2021-11-06 11:11:09 +01:00
Alejandro Celaya
7a2d0e5dee Added sorting dropdown for tags, that can be used regardless the display mode 2021-11-06 11:03:56 +01:00
Alejandro Celaya
daf076a57e Moved logic to sort tags to TagsList component, to allow sorting on any context 2021-11-06 10:55:01 +01:00
Alejandro Celaya
af08b53002 Merge pull request #514 from acelaya-forks/feature/sticky-tags-header
Feature/sticky tags header
2021-11-01 14:16:00 +01:00
Alejandro Celaya
39d5853fe3 Added tests for ordering logic in TagsTable 2021-11-01 14:10:57 +01:00
Alejandro Celaya
9cbeef1cb4 Moved test to the right place 2021-11-01 13:57:53 +01:00
Alejandro Celaya
2857e59273 Updated changelog 2021-11-01 13:45:30 +01:00
Alejandro Celaya
04571ea634 Added logic to order tags list 2021-11-01 13:42:53 +01:00
Alejandro Celaya
5241925acc Added not-enabled sorting on tags table 2021-11-01 12:48:11 +01:00
Alejandro Celaya
844cf51d04 Added missing prettify on number of visits to export and selected visits 2021-11-01 11:19:20 +01:00
Alejandro Celaya
b0c1549005 Added sticky header to tags table 2021-11-01 11:13:51 +01:00
Alejandro Celaya
16d2e437b6 Merge pull request #513 from acelaya-forks/feature/update-test-deps
Feature/update test deps
2021-10-31 12:44:22 +01:00
Alejandro Celaya
944b166e43 Added explicit any type on caught errors where needed 2021-10-31 12:38:42 +01:00
Alejandro Celaya
e5f99d0893 Removed remaining instances of setImmediate in tests 2021-10-31 12:33:17 +01:00
Alejandro Celaya
57e73dcba6 Fixed unhandled promise in remoteServers.test 2021-10-31 12:20:02 +01:00
Alejandro Celaya
80f0f9bd08 Updated test libs 2021-10-31 12:07:38 +01:00
Alejandro Celaya
1486d1fba5 Updated enzyme deps 2021-10-31 10:34:10 +01:00
Alejandro Celaya
e28f74169d Merge pull request #512 from acelaya-forks/feature/server-auto-connect
Feature/server auto connect
2021-10-31 00:16:54 +02:00
Alejandro Celaya
2375882c73 Fixed TS compile error in Home component test 2021-10-31 00:12:03 +02:00
Alejandro Celaya
7b344998ea Updated changelog 2021-10-31 00:08:47 +02:00
Alejandro Celaya
e8ea3b4abe Updated to node 16 and allowed to auto-connect to the first server marked as auto-connect 2021-10-31 00:07:38 +02:00
Alejandro Celaya
bd0fca23cf Merge pull request #511 from acelaya-forks/feature/push-visits-in-date
Feature/push visits in date
2021-10-24 22:44:52 +02:00
Alejandro Celaya
6d392ba403 Added more tests covering how real-time visits are filtered out based on date intervals 2021-10-24 22:37:14 +02:00
Alejandro Celaya
e135dd92ec Ensured new visits are pushed to the state only if they match selected date range 2021-10-24 10:31:32 +02:00
Alejandro Celaya
36af3c3dd0 Merge pull request #510 from acelaya-forks/feature/improved-servers-management
Feature/improved servers management
2021-10-23 11:43:11 +02:00
Alejandro Celaya
c0e33d6a6a Updated changelog 2021-10-23 11:34:39 +02:00
Alejandro Celaya
41398f659e Created ManageServers test 2021-10-23 11:33:32 +02:00
Alejandro Celaya
8618519b6b Created ManageServersRowDropdown test 2021-10-23 10:55:52 +02:00
Alejandro Celaya
c7c32b494e Created ManageServersRow test 2021-10-23 10:34:20 +02:00
Alejandro Celaya
ec9fd67b8a Extracted ManageServersRowDropdown to its own component 2021-10-22 20:26:11 +02:00
Alejandro Celaya
7637ce3107 Added logic to toggle auto-connect on servers 2021-10-22 20:13:23 +02:00
Alejandro Celaya
ada5488a6c Ensured export servers btn is not displayed when there are no servers 2021-10-22 19:03:12 +02:00
Alejandro Celaya
478209f50d Improvements on ManageServers 2021-10-22 18:53:00 +02:00
Alejandro Celaya
7f4263966e Created new section to manage servers 2021-10-17 19:13:06 +02:00
Alejandro Celaya
002f280364 Extracted common dropdown-item style 2021-10-17 18:58:38 +02:00
Alejandro Celaya
d8a6676d30 Merge pull request #507 from acelaya-forks/feature/code-coverage
Increased minimum required branch code coverage to 80
2021-10-17 13:47:30 +02:00
Alejandro Celaya
beff6668de Increased minimum required branch code coverage to 80 2021-10-17 13:43:37 +02:00
Alejandro Celaya
4baa901f1c Fixed merge conflicts 2021-10-17 12:41:23 +02:00
Alejandro Celaya
29bf53bf88 Ensured CI is run on develop branch 2021-10-16 18:12:18 +02:00
Alejandro Celaya
d2284cd181 Merge pull request #504 from acelaya-forks/feature/reusable-workflow
Replaced local ci workflow with one from external repo
2021-10-16 18:10:55 +02:00
Alejandro Celaya
88305a57bf Replaced local ci workflow with one from external repo 2021-10-16 18:05:06 +02:00
Alejandro Celaya
f4908cacc3 Merge pull request #502 from acelaya-forks/feature/forward-query
Feature/forward query
2021-10-13 23:17:52 +02:00
Alejandro Celaya
2925752fde Updated changelog 2021-10-13 23:11:17 +02:00
Alejandro Celaya
1bf3569774 Allowed to customize initial state for forward query 2021-10-13 23:10:22 +02:00
Alejandro Celaya
9e6907deb4 Added forward query component to short URL form 2021-10-13 22:50:48 +02:00
Alejandro Celaya
eaa6efe803 Merge pull request #501 from acelaya-forks/feature/allow-all-default
Feature/allow all default
2021-10-03 21:18:37 +02:00
Alejandro Celaya
d38020e2d1 Updated changelog 2021-10-03 21:12:06 +02:00
Alejandro Celaya
4c1d285d04 Ensured the 'all' item is selected when custom date ranges are unselected 2021-10-03 21:09:48 +02:00
Alejandro Celaya
c71e0919e9 Allowed to select 'all' as the default interval for visits 2021-10-03 21:07:07 +02:00
141 changed files with 38833 additions and 6911 deletions

View File

@@ -5,3 +5,4 @@
./test ./test
./shlink-web-client.gif ./shlink-web-client.gif
./dist ./dist
./docs

View File

@@ -16,6 +16,7 @@
}, },
"ignorePatterns": ["src/service*.ts"], "ignorePatterns": ["src/service*.ts"],
"rules": { "rules": {
"complexity": "off" "complexity": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off"
} }
} }

View File

@@ -5,54 +5,12 @@ on:
push: push:
branches: branches:
- main - main
- develop
jobs: jobs:
lint: ci:
runs-on: ubuntu-20.04 uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
steps: with:
- name: Checkout code node-version: 16.13
uses: actions/checkout@v2 with-mutation-tests: true
- name: Use node.js 14.15 publish-coverage: true
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run lint
unit-tests:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run test:ci
- name: Publish coverage
uses: codecov/codecov-action@v1
with:
file: ./coverage/clover.xml
mutation-tests:
continue-on-error: true
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 0 # needed so that the main branch is also fetched
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
build-docker-image:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- run: docker build -t shlink-web-client:test .

View File

@@ -13,10 +13,10 @@ jobs:
with: with:
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
- name: Use node.js 14.15 - name: Use node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 14.15 node-version: 16.13
- name: Build - name: Build
run: | run: |
npm ci && \ npm ci && \

View File

@@ -11,10 +11,10 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Use node.js 14.15 - name: Use node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 14.15 node-version: 16.13
- name: Generate release assets - name: Generate release assets
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v} run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
- name: Publish release with assets - name: Publish release with assets

View File

@@ -4,6 +4,65 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.4.2] - 2021-12-07
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#530](https://github.com/shlinkio/shlink-web-client/issues/530) Fixed crash on domains page when default domain has an explicitly set port.
## [3.4.1] - 2021-11-20
### Added
* [#525](https://github.com/shlinkio/shlink-web-client/issues/525) Added docs section for Architectural Decision Records, including the one for servers "auto-connect".
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#520](https://github.com/shlinkio/shlink-web-client/issues/520) Fixed landing page scroll on mobile devices and improved its design.
* [#526](https://github.com/shlinkio/shlink-web-client/issues/526) Ensured exported servers do not include the `autoConnect` prop.
## [3.4.0] - 2021-11-11
### Added
* [#496](https://github.com/shlinkio/shlink-web-client/issues/496) Allowed to select "all visits" as the default interval for visits.
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
* [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens.
* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky.
* [#515](https://github.com/shlinkio/shlink-web-client/issues/515) Allowed to sort tags even when using the cards display mode.
* [#518](https://github.com/shlinkio/shlink-web-client/issues/518) Improved short URLs list filtering by moving selected tags, search text and dates to the query string, allowing to navigate back and forth or even bookmark filters.
### Changed
* Moved ci workflow to external repo and reused
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#252](https://github.com/shlinkio/shlink-web-client/issues/252) Fixed visits coming from mercure being added in real time, even when selected date interval does not match tha visit's date.
* [#48](https://github.com/shlinkio/shlink-web-client/issues/48) Fixed error when selected page gets out of range after filtering short URLs list by text, tags or dates. Now the page is reset to 1 in any of those cases.
## [3.3.2] - 2021-10-17 ## [3.3.2] - 2021-10-17
### Added ### Added
* *Nothing* * *Nothing*

View File

@@ -1,4 +1,4 @@
FROM node:14.17-alpine as node FROM node:16.13-alpine as node
COPY . /shlink-web-client COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION ${VERSION} ENV VERSION ${VERSION}

View File

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

View File

@@ -3,7 +3,7 @@ version: '3'
services: services:
shlink_web_client_node: shlink_web_client_node:
container_name: shlink_web_client_node container_name: shlink_web_client_node
image: node:14.17-alpine image: node:16.13-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start" command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www

View File

@@ -0,0 +1,51 @@
# How to handle setting auto-connect on servers
* Status: Accepted
* Date: 2021-10-31
## Context and problem statement
A new feature has been requested, to allow auto-connecting to servers. The request specifically mentioned doing it automatically when there's only one server configured, but it can be extended a bit to allow setting an "auto-connect" server, regardless the number of configured servers.
At all times, no more than one server can be set to "auto-connect" simultaneously. Setting a new one will effectively unset the previous one, if any.
## Considered option
* Auto-connect only of there's a single server configured.
* Allow to set the server as "auto-connect" during server creation, edition or import.
* Allow to set the server as "auto-connect" on a separated flow, where the full list of servers can be handled.
## Decision outcome
In order to make it more flexible, any server will be allowed to be set as "auto-connect", regardless the amount of configured servers.
Auto-connect will be handled from the new "Manage servers" section.
## Pros and Cons of the Options
### Only one server
* Good:
* Does not require extending models, and the logic to auto-connect is based on the amount of configured servers.
* Bad:
* It's not flexible enough.
* Makes the app behave differently depending on the amount of configured servers, making it confusing.
### Auto-connect configured on existing creation/edition/import
* Good:
* Does not require creating a new section to handle "auto-connect".
* Bad:
* Requires extending the server model with a new prop.
* It's much harder to ensure data consistency, as we need to ensure only one server is set to "auto-connect".
* On import, many servers might have been set to "auto-connect". The expected behavior there can be unclear.
### Auto-connect configured on new section
* Good:
* It's much easier to ensure data consistency.
* It's much more clear and predictable, as the UI shows which is the server configured as auto-connect.
* We have controls in a single place to set/unset auto connect on servers, allowing only the proper option based on current state for every server.
* Bad:
* Requires extending the server model with a new prop.
* Requires creating a new section to handle "auto-connect".

5
docs/adr/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Architectural Decision Records
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2021-10-31 How to handle setting auto-connect on servers](2021-10-31-how-to-handle-setting-auto-connect-on-servers.md)

View File

@@ -10,7 +10,7 @@ module.exports = {
coverageThreshold: { coverageThreshold: {
global: { global: {
statements: 85, statements: 85,
branches: 75, branches: 80,
functions: 80, functions: 80,
lines: 85, lines: 85,
}, },

42624
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -69,13 +69,13 @@
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.8", "@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2", "@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^5.0.0", "@stryker-mutator/core": "^5.4.1",
"@stryker-mutator/jest-runner": "^5.0.0", "@stryker-mutator/jest-runner": "^5.4.1",
"@stryker-mutator/typescript-checker": "^5.0.0", "@stryker-mutator/typescript-checker": "^5.4.1",
"@svgr/webpack": "^5.5.0", "@svgr/webpack": "^5.5.0",
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.8", "@types/enzyme": "^3.10.10",
"@types/jest": "^26.0.20", "@types/jest": "^27.0.2",
"@types/leaflet": "^1.5.23", "@types/leaflet": "^1.5.23",
"@types/qs": "^6.9.5", "@types/qs": "^6.9.5",
"@types/ramda": "^0.27.38", "@types/ramda": "^0.27.38",
@@ -89,18 +89,18 @@
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/react-tag-autocomplete": "^6.1.0", "@types/react-tag-autocomplete": "^6.1.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"adm-zip": "^0.4.16", "adm-zip": "^0.4.16",
"autoprefixer": "^10.0.2", "autoprefixer": "^10.0.2",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"babel-jest": "^26.6.3", "babel-jest": "^27.3.1",
"babel-loader": "^8.2.1", "babel-loader": "^8.2.1",
"babel-plugin-named-asset-import": "^0.3.7", "babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0", "babel-preset-react-app": "^10.0.0",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"bfj": "^7.0.2", "bfj": "^7.0.2",
"case-sensitive-paths-webpack-plugin": "^2.3.0", "case-sensitive-paths-webpack-plugin": "^2.3.0",
"chalk": "^4.1.0", "chalk": "^4.1.2",
"css-loader": "^5.0.1", "css-loader": "^5.0.1",
"dart-sass": "^1.25.0", "dart-sass": "^1.25.0",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
@@ -113,9 +113,9 @@
"fs-extra": "^9.0.1", "fs-extra": "^9.0.1",
"html-webpack-plugin": "^4.5.0", "html-webpack-plugin": "^4.5.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3", "jest": "^27.3.1",
"jest-pnp-resolver": "^1.2.2", "jest-pnp-resolver": "^1.2.2",
"jest-resolve": "^26.6.2", "jest-resolve": "^27.3.1",
"mini-css-extract-plugin": "^1.3.1", "mini-css-extract-plugin": "^1.3.1",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"optimize-css-assets-webpack-plugin": "^5.0.4", "optimize-css-assets-webpack-plugin": "^5.0.4",
@@ -141,9 +141,9 @@
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
"sw-precache-webpack-plugin": "^1.0.0", "sw-precache-webpack-plugin": "^1.0.0",
"terser-webpack-plugin": "^4.2.3", "terser-webpack-plugin": "^4.2.3",
"ts-jest": "^26.5.2", "ts-jest": "^27.0.7",
"ts-mockery": "^1.2.0", "ts-mockery": "^1.2.0",
"typescript": "^4.2.2", "typescript": "^4.4.4",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^4.44.2", "webpack": "^4.44.2",
"webpack-dev-server": "^3.11.0", "webpack-dev-server": "^3.11.0",

View File

@@ -1,6 +1,5 @@
import { isEmpty, isNil, reject } from 'ramda'; import { isEmpty, isNil, reject } from 'ramda';
import { AxiosInstance, AxiosResponse, Method } from 'axios'; import { AxiosInstance, AxiosResponse, Method } from 'axios';
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
import { ShortUrl, ShortUrlData } from '../../short-urls/data'; import { ShortUrl, ShortUrlData } from '../../short-urls/data';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { import {
@@ -17,6 +16,7 @@ import {
ShlinkVisitsOverview, ShlinkVisitsOverview,
ShlinkEditDomainRedirects, ShlinkEditDomainRedirects,
ShlinkDomainRedirects, ShlinkDomainRedirects,
ShlinkShortUrlsListParams,
} from '../types'; } from '../types';
import { stringifyQuery } from '../../utils/helpers/query'; import { stringifyQuery } from '../../utils/helpers/query';
@@ -34,7 +34,7 @@ export default class ShlinkApiClient {
this.apiVersion = 2; this.apiVersion = 2;
} }
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> => public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params) this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
.then(({ data }) => data.shortUrls); .then(({ data }) => data.shortUrls);
@@ -125,7 +125,7 @@ export default class ShlinkApiClient {
data: body, data: body,
paramsSerializer: stringifyQuery, paramsSerializer: stringifyQuery,
}); });
} catch (e) { } catch (e: any) {
const { response } = e; const { response } = e;
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error // Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error

View File

@@ -1,6 +1,7 @@
import { Visit } from '../../visits/types'; import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
export interface ShlinkShortUrlsResponse { export interface ShlinkShortUrlsResponse {
data: ShortUrl[]; data: ShortUrl[];
@@ -25,12 +26,12 @@ interface ShlinkTagsStats {
export interface ShlinkTags { export interface ShlinkTags {
tags: string[]; tags: string[];
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2 stats: ShlinkTagsStats[];
} }
export interface ShlinkTagsResponse { export interface ShlinkTagsResponse {
data: string[]; data: string[];
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2 stats: ShlinkTagsStats[];
} }
export interface ShlinkPaginator { export interface ShlinkPaginator {
@@ -85,12 +86,21 @@ export interface ShlinkDomainsResponse {
data: ShlinkDomain[]; data: ShlinkDomain[];
} }
export interface ShlinkShortUrlsListParams {
page?: string;
itemsPerPage?: number;
tags?: string[];
searchTerm?: string;
startDate?: string;
endDate?: string;
orderBy?: OrderBy;
}
export interface ProblemDetailsError { export interface ProblemDetailsError {
type: string; type: string;
detail: string; detail: string;
title: string; title: string;
status: number; status: number;
[extraProps: string]: any; [extraProps: string]: any;
} }

View File

@@ -1,5 +1,6 @@
import { useEffect, FC } from 'react'; import { useEffect, FC } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
import classNames from 'classnames';
import NotFound from '../common/NotFound'; import NotFound from '../common/NotFound';
import { ServersMap } from '../servers/data'; import { ServersMap } from '../servers/data';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
@@ -8,7 +9,7 @@ import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { forceUpdate } from '../utils/helpers/sw'; import { forceUpdate } from '../utils/helpers/sw';
import './App.scss'; import './App.scss';
interface AppProps { interface AppProps extends RouteChildrenProps {
fetchServers: () => void; fetchServers: () => void;
servers: ServersMap; servers: ServersMap;
settings: Settings; settings: Settings;
@@ -23,8 +24,11 @@ const App = (
CreateServer: FC, CreateServer: FC,
EditServer: FC, EditServer: FC,
Settings: FC, Settings: FC,
ManageServers: FC,
ShlinkVersionsContainer: FC, ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => { ) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
const isHome = location.pathname === '/';
useEffect(() => { useEffect(() => {
// On first load, try to fetch the remote servers if the list is empty // On first load, try to fetch the remote servers if the list is empty
if (Object.keys(servers).length === 0) { if (Object.keys(servers).length === 0) {
@@ -39,10 +43,11 @@ const App = (
<MainHeader /> <MainHeader />
<div className="app"> <div className="app">
<div className="shlink-wrapper"> <div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
<Switch> <Switch>
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route exact path="/settings" component={Settings} /> <Route exact path="/settings" component={Settings} />
<Route exact path="/manage-servers" component={ManageServers} />
<Route exact path="/server/create" component={CreateServer} /> <Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} /> <Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} /> <Route path="/server/:serverId" component={MenuLayout} />

View File

@@ -1,9 +1,9 @@
import Bottle from 'bottlejs'; import Bottle, { Decorator } from 'bottlejs';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates'; import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
import App from '../App'; import App from '../App';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Components // Components
bottle.serviceFactory( bottle.serviceFactory(
'App', 'App',
@@ -14,9 +14,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'CreateServer', 'CreateServer',
'EditServer', 'EditServer',
'Settings', 'Settings',
'ManageServers',
'ShlinkVersionsContainer', 'ShlinkVersionsContainer',
); );
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ])); bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
bottle.decorator('App', withRouter);
// Actions // Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable); bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);

View File

@@ -40,7 +40,8 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => ( const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, showOnMobile = false }: AsideMenuProps, { selectedServer, showOnMobile = false }: AsideMenuProps,
) => { ) => {
const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const addManageDomainsLink = supportsDomainRedirects(selectedServer); const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', { const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
@@ -77,7 +78,7 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={editIcon} /> <FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span> <span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem> </AsideMenuItem>
{isServerWithId(selectedServer) && ( {hasId && (
<DeleteServerButton <DeleteServerButton
className="aside-menu__item aside-menu__item--danger" className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text" textClassName="aside-menu__item-text"

View File

@@ -4,6 +4,7 @@
.home { .home {
position: relative; position: relative;
padding-top: 15px; padding-top: 15px;
width: 100%;
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
padding-top: 0; padding-top: 0;

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { isEmpty, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom'; import { Link, RouteChildrenProps } from 'react-router-dom';
import { Card, Row } from 'reactstrap'; import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -9,14 +10,21 @@ import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss'; import './Home.scss';
export interface HomeProps { export interface HomeProps extends RouteChildrenProps {
servers: ServersMap; servers: ServersMap;
} }
const Home = ({ servers }: HomeProps) => { const Home = ({ servers, history }: HomeProps) => {
const serversList = values(servers); const serversList = values(servers);
const hasServers = !isEmpty(serversList); const hasServers = !isEmpty(serversList);
useEffect(() => {
// Try to redirect to the first server marked as auto-connect
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
autoConnectServer && history.push(`/server/${autoConnectServer.id}`);
}, []);
return ( return (
<div className="home"> <div className="home">
<Card className="home__main-card"> <Card className="home__main-card">

View File

@@ -13,7 +13,7 @@ import './MenuLayout.scss';
const MenuLayout = ( const MenuLayout = (
TagsList: FC, TagsList: FC,
ShortUrls: FC, ShortUrlsList: FC,
AsideMenu: FC<AsideMenuProps>, AsideMenu: FC<AsideMenuProps>,
CreateShortUrl: FC, CreateShortUrl: FC,
ShortUrlVisits: FC, ShortUrlVisits: FC,
@@ -49,7 +49,7 @@ const MenuLayout = (
<Switch> <Switch>
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" /> <Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
<Route exact path="/server/:serverId/overview" component={Overview} /> <Route exact path="/server/:serverId/overview" component={Overview} />
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} /> <Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} /> <Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} /> <Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} /> <Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />

View File

@@ -28,13 +28,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('Home', () => Home); bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer); bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', withRouter);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ])); bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory( bottle.serviceFactory(
'MenuLayout', 'MenuLayout',
MenuLayout, MenuLayout,
'TagsList', 'TagsList',
'ShortUrls', 'ShortUrlsList',
'AsideMenu', 'AsideMenu',
'CreateShortUrl', 'CreateShortUrl',
'ShortUrlVisits', 'ShortUrlVisits',
@@ -45,7 +46,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'EditShortUrl', 'EditShortUrl',
'ManageDomains', 'ManageDomains',
); );
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter); bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton'); bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');

View File

@@ -33,10 +33,10 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
actionServiceNames.reduce(mapActionService, {}), actionServiceNames.reduce(mapActionService, {}),
); );
provideAppServices(bottle, connect); provideAppServices(bottle, connect, withRouter);
provideCommonServices(bottle, connect, withRouter); provideCommonServices(bottle, connect, withRouter);
provideApiServices(bottle); provideApiServices(bottle);
provideShortUrlsServices(bottle, connect); provideShortUrlsServices(bottle, connect, withRouter);
provideServersServices(bottle, connect, withRouter); provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect); provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect); provideVisitsServices(bottle, connect);

View File

@@ -25,7 +25,7 @@ const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
); );
const DefaultDomain: FC = () => ( const DefaultDomain: FC = () => (
<> <>
<FontAwesomeIcon icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" /> <FontAwesomeIcon fixedWidth icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip> <UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
</> </>
); );
@@ -33,7 +33,6 @@ const DefaultDomain: FC = () => (
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => { export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
const [ isOpen, toggle ] = useToggle(); const [ isOpen, toggle ] = useToggle();
const { domain: authority, isDefault, redirects } = domain; const { domain: authority, isDefault, redirects } = domain;
const domainId = `domainEdit${authority.replace(/\./g, '')}`;
return ( return (
<tr className="responsive-table__row"> <tr className="responsive-table__row">
@@ -49,13 +48,13 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />} {redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
</td> </td>
<td className="responsive-table__cell text-right"> <td className="responsive-table__cell text-right">
<span id={domainId}> <span id={isDefault ? 'defaultDomainBtn' : undefined}>
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}> <Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
<FontAwesomeIcon icon={isDefault ? forbiddenIcon : editIcon} /> <FontAwesomeIcon fixedWidth icon={isDefault ? forbiddenIcon : editIcon} />
</Button> </Button>
</span> </span>
{isDefault && ( {isDefault && (
<UncontrolledTooltip target={domainId} placement="left"> <UncontrolledTooltip target="defaultDomainBtn" placement="left">
Redirects for default domain cannot be edited here. Redirects for default domain cannot be edited here.
<br /> <br />
Use config options or env vars directly on the server. Use config options or env vars directly on the server.

View File

@@ -27,7 +27,7 @@ export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder
const redirects = await editDomainRedirects({ domain, ...domainRedirects }); const redirects = await editDomainRedirects({ domain, ...domainRedirects });
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
} }
}; };

View File

@@ -71,7 +71,7 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
const domains = await listDomains(); const domains = await listDomains();
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains }); dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
} }
}; };

View File

@@ -115,6 +115,16 @@ hr {
color: var(--text-color) !important; color: var(--text-color) !important;
} }
.dropdown-item--danger.dropdown-item--danger {
color: $dangerColor;
&:hover,
&:active,
&.active {
color: $dangerColor !important;
}
}
.badge-main { .badge-main {
color: #ffffff; color: #ffffff;
background-color: var(--brand-color); background-color: var(--brand-color);

View File

@@ -8,9 +8,3 @@
text-align: right; text-align: right;
} }
} }
.create-server__csv-select {
position: absolute;
left: -9999px;
top: -9999px;
}

View File

@@ -1,30 +1,35 @@
import { FC } from 'react'; import { FC } from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { RouterProps } from 'react-router'; import { RouterProps } from 'react-router';
import { Button } from 'reactstrap';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import NoMenuLayout from '../common/NoMenuLayout'; import NoMenuLayout from '../common/NoMenuLayout';
import { StateFlagTimeout } from '../utils/helpers/hooks'; import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServerWithId } from './data'; import { ServerData, ServersMap, ServerWithId } from './data';
import './CreateServer.scss'; import './CreateServer.scss';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps extends RouterProps { interface CreateServerProps extends RouterProps {
createServer: (server: ServerWithId) => void; createServer: (server: ServerWithId) => void;
servers: ServersMap;
} }
const ImportResult = ({ type }: { type: 'error' | 'success' }) => ( const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<Result type={type}> <div className="mt-3">
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'} <Result type={type}>
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'} {type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
</Result> {type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result>
</div>
); );
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => ( const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
{ createServer, history: { push } }: CreateServerProps, { servers, createServer, history: { push, goBack } }: CreateServerProps,
) => { ) => {
const hasServers = !!Object.keys(servers).length;
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const handleSubmit = (serverData: ServerData) => { const handleSubmit = (serverData: ServerData) => {
@@ -37,16 +42,14 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}> <ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} /> {!hasServers &&
<button className="btn btn-outline-primary">Create server</button> <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ml-2">Create server</Button>
</ServerForm> </ServerForm>
{(serversImported || errorImporting) && ( {serversImported && <ImportResult type="success" />}
<div className="mt-3"> {errorImporting && <ImportResult type="error" />}
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
</div>
)}
</NoMenuLayout> </NoMenuLayout>
); );
}; };

View File

@@ -1,3 +1,4 @@
import { FC } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { RouterProps } from 'react-router'; import { RouterProps } from 'react-router';
import { ServerWithId } from './data'; import { ServerWithId } from './data';
@@ -6,17 +7,20 @@ export interface DeleteServerModalProps {
server: ServerWithId; server: ServerWithId;
toggle: () => void; toggle: () => void;
isOpen: boolean; isOpen: boolean;
redirectHome?: boolean;
} }
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps { interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
deleteServer: (server: ServerWithId) => void; deleteServer: (server: ServerWithId) => void;
} }
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => { const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
) => {
const closeModal = () => { const closeModal = () => {
deleteServer(server); deleteServer(server);
toggle(); toggle();
history.push('/'); redirectHome && history.push('/');
}; };
return ( return (

View File

@@ -10,7 +10,7 @@ interface EditServerProps {
} }
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(( export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
{ editServer, selectedServer, history: { push, goBack } }, { editServer, selectedServer, history: { goBack } },
) => { ) => {
if (!isServerWithId(selectedServer)) { if (!isServerWithId(selectedServer)) {
return null; return null;
@@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
const handleSubmit = (serverData: ServerData) => { const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData); editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}`); goBack();
}; };
return ( return (

View File

@@ -0,0 +1,86 @@
import { FC, useEffect, useState } from 'react';
import { Button, Row } from 'reactstrap';
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import NoMenuLayout from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField';
import { Result } from '../utils/Result';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServersMap } from './data';
import { ManageServersRowProps } from './ManageServersRow';
import ServersExporter from './services/ServersExporter';
interface ManageServersProps {
servers: ServersMap;
}
const SHOW_IMPORT_MSG_TIME = 4000;
export const ManageServers = (
serversExporter: ServersExporter,
ImportServersBtn: FC<ImportServersBtnProps>,
useStateFlagTimeout: StateFlagTimeout,
ManageServersRow: FC<ManageServersRowProps>,
): FC<ManageServersProps> => ({ servers }) => {
const allServers = Object.values(servers);
const [ serversList, setServersList ] = useState(allServers);
const filterServers = (searchTerm: string) => setServersList(
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
);
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
useEffect(() => {
setServersList(Object.values(servers));
}, [ servers ]);
return (
<NoMenuLayout>
<SearchField className="mb-3" onChange={filterServers} />
<Row className="mb-3">
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{allServers.length > 0 && (
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button>
)}
</div>
<div className="col-md-6 text-md-right d-flex d-md-block">
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button>
</div>
</Row>
<SimpleCard>
<table className="table table-hover mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th style={{ width: '50px' }} />}
<th>Name</th>
<th>Base URL</th>
<th />
</tr>
</thead>
<tbody>
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
{serversList.map((server) =>
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />)
}
</tbody>
</table>
</SimpleCard>
{errorImporting && (
<div className="mt-3">
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
</div>
)}
</NoMenuLayout>
);
};

View File

@@ -0,0 +1,38 @@
import { FC } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
import { ServerWithId } from './data';
import { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
export interface ManageServersRowProps {
server: ServerWithId;
hasAutoConnect: boolean;
}
export const ManageServersRow = (
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>,
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => (
<tr className="responsive-table__row">
{hasAutoConnect && (
<td className="responsive-table__cell" data-th="Auto-connect">
{server.autoConnect && (
<>
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon" placement="right">
Auto-connect to this server
</UncontrolledTooltip>
</>
)}
</td>
)}
<th className="responsive-table__cell" data-th="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link>
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-right">
<ManageServersRowDropdown server={server} />
</td>
</tr>
);

View File

@@ -0,0 +1,53 @@
import { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBan as toggleOffIcon,
faEdit as editIcon,
faMinusCircle as deleteIcon,
faPlug as connectIcon,
} from '@fortawesome/free-solid-svg-icons';
import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import { useToggle } from '../utils/helpers/hooks';
import { DeleteServerModalProps } from './DeleteServerModal';
import { ServerWithId } from './data';
export interface ManageServersRowDropdownProps {
server: ServerWithId;
}
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps {
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
}
export const ManageServersRowDropdown = (
DeleteServerModal: FC<DeleteServerModalProps>,
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
const [ isMenuOpen, toggleMenu ] = useToggle();
const [ isModalOpen,, showModal, hideModal ] = useToggle();
const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
return (
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem onClick={() => setAutoConnect(server, !server.autoConnect)}>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem>
<DropdownItem divider />
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem>
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
</DropdownBtnMenu>
);
};

View File

@@ -1,7 +1,6 @@
import { FC, useEffect } from 'react'; import { FC, useEffect } from 'react';
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap'; import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList'; import { TagsList } from '../tags/reducers/tagsList';
@@ -11,12 +10,13 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Versions } from '../utils/helpers/version'; import { Versions } from '../utils/helpers/version';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { isServerWithId, SelectedServer } from './data'; import { ShlinkShortUrlsListParams } from '../api/types';
import { getServerId, SelectedServer } from './data';
import './Overview.scss'; import './Overview.scss';
interface OverviewConnectProps { interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void; listShortUrls: (params: ShlinkShortUrlsListParams) => void;
listTags: Function; listTags: Function;
tagsList: TagsList; tagsList: TagsList;
selectedServer: SelectedServer; selectedServer: SelectedServer;
@@ -40,7 +40,7 @@ export const Overview = (
const { loading, shortUrls } = shortUrlsList; const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview; const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; const serverId = getServerId(selectedServer);
const history = useHistory(); const history = useHistory();
useEffect(() => { useEffect(() => {
@@ -107,7 +107,7 @@ export const Overview = (
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
selectedServer={selectedServer} selectedServer={selectedServer}
className="mb-0" className="mb-0"
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)} onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/> />
</CardBody> </CardBody>
</Card> </Card>

View File

@@ -1,45 +1,37 @@
import { isEmpty, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersExporter from './services/ServersExporter'; import { getServerId, SelectedServer, ServersMap } from './data';
import { isServerWithId, SelectedServer, ServersMap } from './data';
export interface ServersDropdownProps { export interface ServersDropdownProps {
servers: ServersMap; servers: ServersMap;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => { const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = values(servers); const serversList = values(servers);
const createServerItem = (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
</DropdownItem>
);
const renderServers = () => { const renderServers = () => {
if (isEmpty(serversList)) { if (isEmpty(serversList)) {
return createServerItem; return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
</DropdownItem>
);
} }
return ( return (
<> <>
{serversList.map(({ name, id }) => ( {serversList.map(({ name, id }) => (
<DropdownItem <DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
key={id}
tag={Link}
to={`/server/${id}`}
active={isServerWithId(selectedServer) && selectedServer.id === id}
>
{name} {name}
</DropdownItem> </DropdownItem>
))} ))}
<DropdownItem divider /> <DropdownItem divider />
{createServerItem} <DropdownItem tag={Link} to="/manage-servers">
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}> <FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
</DropdownItem> </DropdownItem>
</> </>
); );

View File

@@ -1,3 +1,4 @@
import { omit } from 'ramda';
import { SemVer } from '../../utils/helpers/version'; import { SemVer } from '../../utils/helpers/version';
export interface ServerData { export interface ServerData {
@@ -8,6 +9,7 @@ export interface ServerData {
export interface ServerWithId extends ServerData { export interface ServerWithId extends ServerData {
id: string; id: string;
autoConnect?: boolean;
} }
export interface ReachableServer extends ServerWithId { export interface ReachableServer extends ServerWithId {
@@ -42,3 +44,6 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ
!!server?.hasOwnProperty('serverNotFound'); !!server?.hasOwnProperty('serverNotFound');
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : ''; export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
omit<ServerWithId, 'id' | 'autoConnect'>([ 'id', 'autoConnect' ], server);

View File

@@ -0,0 +1,5 @@
.import-servers-btn__csv-select {
position: absolute;
left: -9999px;
top: -9999px;
}

View File

@@ -1,13 +1,19 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react'; import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react';
import { UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersImporter from '../services/ServersImporter'; import ServersImporter from '../services/ServersImporter';
import { ServerData } from '../data'; import { ServerData } from '../data';
import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>; type Ref<T> = RefObject<T> | MutableRefObject<T>;
export interface ImportServersBtnProps { export interface ImportServersBtnProps {
onImport?: () => void; onImport?: () => void;
onImportError?: (error: Error) => void; onImportError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom';
className?: string;
} }
interface ImportServersBtnConnectProps extends ImportServersBtnProps { interface ImportServersBtnConnectProps extends ImportServersBtnProps {
@@ -15,17 +21,19 @@ interface ImportServersBtnConnectProps extends ImportServersBtnProps {
fileRef: Ref<HTMLInputElement>; fileRef: Ref<HTMLInputElement>;
} }
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
createServers, createServers,
fileRef, fileRef,
children,
onImport = () => {}, onImport = () => {},
onImportError = () => {}, onImportError = () => {},
}: ImportServersBtnConnectProps) => { tooltipPlacement = 'bottom',
className = '',
}) => {
const ref = fileRef ?? useRef<HTMLInputElement>(); const ref = fileRef ?? useRef<HTMLInputElement>();
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) => const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
importServersFromFile(target.files?.[0]) importServersFromFile(target.files?.[0])
.then(createServers) .then(pipe(createServers, onImport))
.then(onImport)
.then(() => { .then(() => {
// Reset input after processing file // Reset input after processing file
(target as { value: string | null }).value = null; (target as { value: string | null }).value = null;
@@ -34,19 +42,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
return ( return (
<> <>
<button <Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
type="button" <FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
className="btn btn-outline-secondary mr-2" </Button>
id="importBtn" <UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
onClick={() => ref.current?.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>. You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip> </UncontrolledTooltip>
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} /> <input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onChange} />
</> </>
); );
}; };

View File

@@ -31,7 +31,7 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
<SimpleCard className="mb-3" title={title}> <SimpleCard className="mb-3" title={title}>
<FormGroup value={name} onChange={setName}>Name</FormGroup> <FormGroup value={name} onChange={setName}>Name</FormGroup>
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup> <FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup> <FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
</SimpleCard> </SimpleCard>
<div className="text-right">{children}</div> <div className="text-right">{children}</div>

View File

@@ -2,24 +2,17 @@ import { pipe, prop } from 'ramda';
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { homepage } from '../../../package.json'; import { homepage } from '../../../package.json';
import { ServerData } from '../data'; import { hasServerData, ServerData } from '../data';
import { createServers } from './servers'; import { createServers } from './servers';
const responseToServersList = pipe( const responseToServersList = pipe(
prop<any, any>('data'), prop<any, any>('data'),
(data: any): ServerData[] => { (data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [],
if (!Array.isArray(data)) {
throw new Error('Value is not an array');
}
return data as ServerData[];
},
); );
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => { export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
const remoteList = await get(`${homepage}/servers.json`) const resp = await get(`${homepage}/servers.json`);
.then(responseToServersList) const remoteList = responseToServersList(resp);
.catch(() => []);
dispatch(createServers(remoteList)); dispatch(createServers(remoteList));
}; };

View File

@@ -1,4 +1,4 @@
import { assoc, dissoc, map, pipe, reduce } from 'ramda'; import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Action } from 'redux'; import { Action } from 'redux';
import { ServerData, ServersMap, ServerWithId } from '../data'; import { ServerData, ServersMap, ServerWithId } from '../data';
@@ -8,12 +8,22 @@ import { buildReducer } from '../../utils/helpers/redux';
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER'; export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER'; export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS'; export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export interface CreateServersAction extends Action<string> { export interface CreateServersAction extends Action<string> {
newServers: ServersMap; newServers: ServersMap;
} }
interface DeleteServerAction extends Action<string> {
serverId: string;
}
interface SetAutoConnectAction extends Action<string> {
serverId: string;
autoConnect: boolean;
}
const initialState: ServersMap = {}; const initialState: ServersMap = {};
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => { const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
@@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
return assoc('id', uuid(), server); return assoc('id', uuid(), server);
}; };
export default buildReducer<ServersMap, CreateServersAction>({ export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }), [CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state), [DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId] [EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
? state ? state
: assoc(serverId, { ...state[serverId], ...serverData }, 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); }, initialState);
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {}); const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
@@ -48,4 +74,10 @@ export const editServer = (serverId: string, serverData: Partial<ServerData>) =>
serverData, serverData,
}); });
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id }); export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
type: SET_AUTO_CONNECT,
serverId: id,
autoConnect,
});

View File

@@ -1,7 +1,7 @@
import { dissoc, values } from 'ramda'; import { values } from 'ramda';
import { CsvJson } from 'csvjson'; import { CsvJson } from 'csvjson';
import LocalStorage from '../../utils/services/LocalStorage'; import LocalStorage from '../../utils/services/LocalStorage';
import { ServersMap } from '../data'; import { ServersMap, serverWithIdToServerData } from '../data';
import { saveCsv } from '../../utils/helpers/files'; import { saveCsv } from '../../utils/helpers/files';
const SERVERS_FILENAME = 'shlink-servers.csv'; const SERVERS_FILENAME = 'shlink-servers.csv';
@@ -14,7 +14,7 @@ export default class ServersExporter {
) {} ) {}
public readonly exportServers = async () => { public readonly exportServers = async () => {
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(dissoc('id')); const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
try { try {
const csv = this.csvjson.toCSV(servers, { headers: 'key' }); const csv = this.csvjson.toCSV(servers, { headers: 'key' });

View File

@@ -7,26 +7,44 @@ import DeleteServerButton from '../DeleteServerButton';
import { EditServer } from '../EditServer'; import { EditServer } from '../EditServer';
import ImportServersBtn from '../helpers/ImportServersBtn'; import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer'; import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers'; import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers'; import { fetchServers } from '../reducers/remoteServers';
import ForServerVersion from '../helpers/ForServerVersion'; import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError'; import { ServerError } from '../helpers/ServerError';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { Overview } from '../Overview'; import { Overview } from '../Overview';
import { ManageServers } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow';
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
import ServersImporter from './ServersImporter'; import ServersImporter from './ServersImporter';
import ServersExporter from './ServersExporter'; import ServersExporter from './ServersExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Components // Components
bottle.serviceFactory(
'ManageServers',
ManageServers,
'ServersExporter',
'ImportServersBtn',
'useStateFlagTimeout',
'ManageServersRow',
);
bottle.decorator('ManageServers', connect([ 'servers' ]));
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ]));
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', withoutSelectedServer); bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ])); bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError'); bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ])); bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter'); bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ])); bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
@@ -62,6 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('createServers', () => createServers); bottle.serviceFactory('createServers', () => createServers);
bottle.serviceFactory('deleteServer', () => deleteServer); bottle.serviceFactory('deleteServer', () => deleteServer);
bottle.serviceFactory('editServer', () => editServer); bottle.serviceFactory('editServer', () => editServer);
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
bottle.serviceFactory('fetchServers', fetchServers, 'axios'); bottle.serviceFactory('fetchServers', fetchServers, 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);

View File

@@ -18,7 +18,7 @@ const RealTimeUpdates = (
<SimpleCard title="Real-time updates" className="h-100"> <SimpleCard title="Real-time updates" className="h-100">
<FormGroup> <FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}> <ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer. Enable or disable real-time updates.
<small className="form-text text-muted"> <small className="form-text text-muted">
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>. Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</small> </small>

View File

@@ -14,8 +14,8 @@ const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): s
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input'; tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode => const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
tagFilteringMode === 'includes' tagFilteringMode === 'includes'
? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</> ? <>The list of suggested tags will contain those <b>including</b> provided input.</>
: <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>; : <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => { export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false }; const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
@@ -24,19 +24,31 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
); );
return ( return (
<SimpleCard title="Short URLs creation" className="h-100"> <SimpleCard title="Short URLs form" className="h-100">
<FormGroup> <FormGroup>
<ToggleSwitch <ToggleSwitch
checked={shortUrlCreation.validateUrls ?? false} checked={shortUrlCreation.validateUrls ?? false}
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })} onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
> >
By default, request validation on long URLs when creating new short URLs. Request validation on long URLs when creating new short URLs.
<small className="form-text text-muted"> <small className="form-text text-muted">
The initial state of the <b>Validate URL</b> checkbox will The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>. be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</small> </small>
</ToggleSwitch> </ToggleSwitch>
</FormGroup> </FormGroup>
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.forwardQuery ?? true}
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
>
Make all new short URLs forward their query params to the long URL.
<small className="form-text text-muted">
The initial state of the <b>Forward query params on redirect</b> checkbox will
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
</small>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0"> <FormGroup className="mb-0">
<label>Tag suggestions search mode:</label> <label>Tag suggestions search mode:</label>
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}> <DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>

View File

@@ -14,6 +14,7 @@ export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<FormGroup className="mb-0"> <FormGroup className="mb-0">
<label>Default interval to load on visits sections:</label> <label>Default interval to load on visits sections:</label>
<DateIntervalSelector <DateIntervalSelector
allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'} active={settings.visits?.defaultInterval ?? 'last30Days'}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })} onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/> />

View File

@@ -22,6 +22,7 @@ export type TagFilteringMode = 'startsWith' | 'includes';
export interface ShortUrlCreationSettings { export interface ShortUrlCreationSettings {
validateUrls: boolean; validateUrls: boolean;
tagFilteringMode?: TagFilteringMode; tagFilteringMode?: TagFilteringMode;
forwardQuery?: boolean;
} }
export type TagsMode = 'cards' | 'list'; export type TagsMode = 'cards' | 'list';

View File

@@ -30,6 +30,7 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
maxVisits: undefined, maxVisits: undefined,
findIfExists: false, findIfExists: false,
validateUrl: settings?.validateUrls ?? false, validateUrl: settings?.validateUrls ?? false,
forwardQuery: settings?.forwardQuery ?? true,
}); });
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({ const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({

View File

@@ -42,6 +42,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
validUntil: shortUrl.meta.validUntil ?? undefined, validUntil: shortUrl.meta.validUntil ?? undefined,
maxVisits: shortUrl.meta.maxVisits ?? undefined, maxVisits: shortUrl.meta.maxVisits ?? undefined,
crawlable: shortUrl.crawlable, crawlable: shortUrl.crawlable,
forwardQuery: shortUrl.forwardQuery,
validateUrl, validateUrl,
}; };
}; };

View File

@@ -1,15 +1,24 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination'; import {
pageIsEllipsis,
keyForPage,
progressivePagination,
prettifyPageNumber,
NumberOrEllipsis,
} from '../utils/helpers/pagination';
import { ShlinkPaginator } from '../api/types'; import { ShlinkPaginator } from '../api/types';
interface PaginatorProps { interface PaginatorProps {
paginator?: ShlinkPaginator; paginator?: ShlinkPaginator;
serverId: string; serverId: string;
currentQueryString?: string;
} }
const Paginator = ({ paginator, serverId }: PaginatorProps) => { const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
const { currentPage = 0, pagesCount = 0 } = paginator ?? {}; const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
const urlForPage = (pageNumber: NumberOrEllipsis) =>
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
if (pagesCount <= 1) { if (pagesCount <= 1) {
return null; return null;
@@ -22,10 +31,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
disabled={pageIsEllipsis(pageNumber)} disabled={pageIsEllipsis(pageNumber)}
active={currentPage === pageNumber} active={currentPage === pageNumber}
> >
<PaginationLink <PaginationLink tag={Link} to={urlForPage(pageNumber)}>
tag={Link}
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
>
{prettifyPageNumber(pageNumber)} {prettifyPageNumber(pageNumber)}
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
@@ -34,19 +40,11 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
return ( return (
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0"> <Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
<PaginationItem disabled={currentPage === 1}> <PaginationItem disabled={currentPage === 1}>
<PaginationLink <PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
previous
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
/>
</PaginationItem> </PaginationItem>
{renderPages()} {renderPages()}
<PaginationItem disabled={currentPage >= pagesCount}> <PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink <PaginationLink next tag={Link} to={urlForPage(currentPage + 1)} />
next
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
/>
</PaginationItem> </PaginationItem>
</Pagination> </Pagination>
); );

View File

@@ -2,35 +2,43 @@ import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { RouteChildrenProps } from 'react-router-dom';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag'; import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import ColorGenerator from '../utils/services/ColorGenerator'; import ColorGenerator from '../utils/services/ColorGenerator';
import { DateRange } from '../utils/dates/types'; import { DateRange } from '../utils/dates/types';
import { ShortUrlsListParams } from './reducers/shortUrlsListParams'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
import './SearchBar.scss'; import './SearchBar.scss';
interface SearchBarProps { export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
listShortUrls: (params: ShortUrlsListParams) => void;
shortUrlsListParams: ShortUrlsListParams;
}
const dateOrNull = (date?: string) => date ? parseISO(date) : null; const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => { const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
const selectedTags = shortUrlsListParams.tags ?? []; const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
const selectedTags = tags?.split(',') ?? [];
const setDates = pipe( const setDates = pipe(
({ startDate, endDate }: DateRange) => ({ ({ startDate, endDate }: DateRange) => ({
startDate: formatIsoDate(startDate) ?? undefined, startDate: formatIsoDate(startDate) ?? undefined,
endDate: formatIsoDate(endDate) ?? undefined, endDate: formatIsoDate(endDate) ?? undefined,
}), }),
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }), toFirstPage,
);
const setSearch = pipe(
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
(search) => toFirstPage({ search }),
);
const removeTag = pipe(
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
(tags) => toFirstPage({ tags }),
); );
return ( return (
<div className="search-bar-container"> <div className="search-bar-container">
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} /> <SearchField initialValue={search} onChange={setSearch} />
<div className="mt-3"> <div className="mt-3">
<div className="row"> <div className="row">
@@ -38,8 +46,8 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
<DateRangeSelector <DateRangeSelector
defaultText="All short URLs" defaultText="All short URLs"
initialDateRange={{ initialDateRange={{
startDate: dateOrNull(shortUrlsListParams.startDate), startDate: dateOrNull(startDate),
endDate: dateOrNull(shortUrlsListParams.endDate), endDate: dateOrNull(endDate),
}} }}
onDatesChange={setDates} onDatesChange={setDates}
/> />
@@ -47,24 +55,12 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
</div> </div>
</div> </div>
{!isEmpty(selectedTags) && ( {selectedTags.length > 0 && (
<h4 className="search-bar__selected-tag mt-3"> <h4 className="search-bar__selected-tag mt-3">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" /> <FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp; &nbsp;
{selectedTags.map((tag) => ( {selectedTags.map((tag) =>
<Tag <Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
colorGenerator={colorGenerator}
key={tag}
text={tag}
clearable
onClose={() => listShortUrls(
{
...shortUrlsListParams,
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
},
)}
/>
))}
</h4> </h4>
)} )}
</div> </div>

View File

@@ -5,7 +5,7 @@ import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
import classNames from 'classnames'; import classNames from 'classnames';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import DateInput, { DateInputProps } from '../utils/DateInput'; import DateInput, { DateInputProps } from '../utils/DateInput';
import { supportsCrawlableVisits, supportsShortUrlTitle } from '../utils/helpers/features'; import { supportsCrawlableVisits, supportsForwardQuery, supportsShortUrlTitle } from '../utils/helpers/features';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils'; import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
import Checkbox from '../utils/Checkbox'; import Checkbox from '../utils/Checkbox';
@@ -33,6 +33,7 @@ export interface ShortUrlFormProps {
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date; const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
const dynamicColClasses = (flag: boolean) => ({ 'col-sm-6': flag, 'col-sm-12': !flag });
export const ShortUrlForm = ( export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
@@ -109,11 +110,11 @@ export const ShortUrlForm = (
const supportsTitle = supportsShortUrlTitle(selectedServer); const supportsTitle = supportsShortUrlTitle(selectedServer);
const showCustomizeCard = supportsTitle || !isEdit; const showCustomizeCard = supportsTitle || !isEdit;
const limitAccessCardClasses = classNames('mb-3', { const limitAccessCardClasses = classNames('mb-3', dynamicColClasses(showCustomizeCard));
'col-sm-6': showCustomizeCard,
'col-sm-12': !showCustomizeCard,
});
const showCrawlableControl = supportsCrawlableVisits(selectedServer); const showCrawlableControl = supportsCrawlableVisits(selectedServer);
const showForwardQueryControl = supportsForwardQuery(selectedServer);
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
const extraChecksCardClasses = classNames('mb-3', dynamicColClasses(showBehaviorCard));
return ( return (
<form className="short-url-form" onSubmit={submit}> <form className="short-url-form" onSubmit={submit}>
@@ -165,37 +166,56 @@ export const ShortUrlForm = (
</div> </div>
</Row> </Row>
<SimpleCard title="Extra checks" className="mb-3"> <Row>
<ShortUrlFormCheckboxGroup <div className={extraChecksCardClasses}>
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible." <SimpleCard title="Extra checks">
checked={shortUrlData.validateUrl} <ShortUrlFormCheckboxGroup
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })} infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
> checked={shortUrlData.validateUrl}
Validate URL onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
</ShortUrlFormCheckboxGroup>
{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>
)}
{!isEdit && (
<p>
<Checkbox
inline
className="mr-2"
checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
> >
Use existing URL if found Validate URL
</Checkbox> </ShortUrlFormCheckboxGroup>
<UseExistingIfFoundInfoIcon /> {!isEdit && (
</p> <p>
<Checkbox
inline
className="mr-2"
checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
)}
</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>
)} )}
</SimpleCard> </Row>
</> </>
)} )}

View File

@@ -1,23 +0,0 @@
import { FC, useEffect, useState } from 'react';
import { ShortUrlsListProps } from './ShortUrlsList';
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
const { match } = props;
const { page = '1', serverId = '' } = match?.params ?? {};
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
// Using a key on a component makes react to create a new instance every time the key changes
// Without it, pagination on the URL will not make the component to be refreshed
useEffect(() => {
setUrlsListKey(`${serverId}_${page}`);
}, [ serverId, page ]);
return (
<>
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} key={urlsListKey} />
</>
);
};
export default ShortUrls;

View File

@@ -1,3 +0,0 @@
.short-urls-list__header-icon {
margin-left: .4rem;
}

View File

@@ -1,27 +1,21 @@
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { head, keys, pipe, values } from 'ramda';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FC, useEffect, useMemo, useState } from 'react';
import { head, keys, values } from 'ramda';
import { FC, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router'; import { RouteComponentProps } from 'react-router';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import SortingDropdown from '../utils/SortingDropdown'; import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir, OrderDir } from '../utils/utils'; import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
import { isReachableServer, SelectedServer } from '../servers/data'; import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { parseQuery } from '../utils/helpers/query';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { ShlinkShortUrlsListParams } from '../api/types';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
import { ShortUrlsTableProps } from './ShortUrlsTable'; import { ShortUrlsTableProps } from './ShortUrlsTable';
import Paginator from './Paginator'; import Paginator from './Paginator';
import './ShortUrlsList.scss'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
interface RouteParams { interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
page: string;
serverId: string;
}
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
selectedServer: SelectedServer; selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void; listShortUrls: (params: ShortUrlsListParams) => void;
@@ -29,73 +23,65 @@ export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
resetShortUrlParams: () => void; resetShortUrlParams: () => void;
} }
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({ type ShortUrlsOrder = Order<OrderableFields>;
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
listShortUrls, listShortUrls,
resetShortUrlParams, resetShortUrlParams,
shortUrlsListParams, shortUrlsListParams,
match, match,
location, location,
history,
shortUrlsList, shortUrlsList,
selectedServer, selectedServer,
}: ShortUrlsListProps) => { }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
const { orderBy } = shortUrlsListParams; const { orderBy } = shortUrlsListParams;
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({ const [ order, setOrder ] = useState<ShortUrlsOrder>({
orderField: orderBy && (head(keys(orderBy)) as OrderableFields), field: orderBy && (head(keys(orderBy)) as OrderableFields),
orderDir: orderBy && head(values(orderBy)), dir: orderBy && head(values(orderBy)),
}); });
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
const { pagination } = shortUrlsList?.shortUrls ?? {}; const { pagination } = shortUrlsList?.shortUrls ?? {};
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => { const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
setOrder({ orderField, orderDir }); { ...shortUrlsListParams, ...extraParams },
refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined }); );
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
setOrder({ field, dir });
refreshList({ orderBy: field ? { [field]: dir } : undefined });
}; };
const orderByColumn = (field: OrderableFields) => () => const orderByColumn = (field: OrderableFields) => () =>
handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir)); handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
const renderOrderIcon = (field: OrderableFields) => { const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
if (order.orderField !== field) { const addTag = pipe(
return null; (newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
} (tags) => toFirstPage({ tags }),
);
if (!order.orderDir) {
return null;
}
return (
<FontAwesomeIcon
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className="short-urls-list__header-icon"
/>
);
};
useEffect(() => resetShortUrlParams, []);
useEffect(() => { useEffect(() => {
const { tag } = parseQuery<{ tag?: string }>(location.search); refreshList(
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags; { page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
);
refreshList({ page: match.params.page, tags, itemsPerPage: undefined }); }, [ match.params.page, search, selectedTags, startDate, endDate ]);
return resetShortUrlParams;
}, []);
return ( return (
<> <>
<div className="mb-3"><SearchBar /></div>
<div className="d-block d-lg-none mb-3"> <div className="d-block d-lg-none mb-3">
<SortingDropdown <SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
items={SORTABLE_FIELDS}
orderField={order.orderField}
orderDir={order.orderDir}
onChange={handleOrderBy}
/>
</div> </div>
<Card body className="pb-1"> <Card body className="pb-1">
<ShortUrlsTable <ShortUrlsTable
orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon}
selectedServer={selectedServer} selectedServer={selectedServer}
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
onTagClick={(tag) => refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })} orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon}
onTagClick={addTag}
/> />
<Paginator paginator={pagination} serverId={isReachableServer(selectedServer) ? selectedServer.id : ''} /> <Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
</Card> </Card>
</> </>
); );

View File

@@ -35,7 +35,9 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
if (error) { if (error) {
return ( return (
<tr> <tr>
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td> <td colSpan={6} className="text-center table-danger text-dark">
Something went wrong while loading short URLs :(
</td>
</tr> </tr>
); );
} }
@@ -63,34 +65,29 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
<thead className="responsive-table__header short-urls-table__header"> <thead className="responsive-table__header short-urls-table__header">
<tr> <tr>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
Created at Created at {renderOrderIcon?.('dateCreated')}
{renderOrderIcon?.('dateCreated')}
</th> </th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
Short URL Short URL {renderOrderIcon?.('shortCode')}
{renderOrderIcon?.('shortCode')}
</th> </th>
{!supportsTitle && ( {!supportsTitle && (
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
Long URL Long URL {renderOrderIcon?.('longUrl')}
{renderOrderIcon?.('longUrl')}
</th> </th>
) || ( ) || (
<th className="short-urls-table__header-cell"> <th className="short-urls-table__header-cell">
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}> <span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
Title Title {renderOrderIcon?.('title')}
{renderOrderIcon?.('title')}
</span> </span>
&nbsp;&nbsp;/&nbsp;&nbsp; &nbsp;&nbsp;/&nbsp;&nbsp;
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}> <span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
<span className="indivisible">Long URL</span> <span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
{renderOrderIcon?.('longUrl')}
</span> </span>
</th> </th>
)} )}
<th className="short-urls-table__header-cell">Tags</th> <th className="short-urls-table__header-cell">Tags</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
<span className="indivisible">Visits{renderOrderIcon?.('visits')}</span> <span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
</th> </th>
<th className="short-urls-table__header-cell">&nbsp;</th> <th className="short-urls-table__header-cell">&nbsp;</th>
</tr> </tr>

View File

@@ -9,6 +9,7 @@ export interface EditShortUrlData {
maxVisits?: number | null; maxVisits?: number | null;
validateUrl?: boolean; validateUrl?: boolean;
crawlable?: boolean; crawlable?: boolean;
forwardQuery?: boolean;
} }
export interface ShortUrlData extends EditShortUrlData { export interface ShortUrlData extends EditShortUrlData {
@@ -30,6 +31,7 @@ export interface ShortUrl {
domain: string | null; domain: string | null;
title?: string | null; title?: string | null;
crawlable?: boolean; crawlable?: boolean;
forwardQuery?: boolean;
} }
export interface ShortUrlMeta { export interface ShortUrlMeta {

View File

@@ -1,11 +0,0 @@
@import '../../utils/base';
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
color: $dangerColor;
&:hover,
&:active,
&.active {
color: $dangerColor !important;
}
}

View File

@@ -12,7 +12,6 @@ import { ShortUrl, ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu'; import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import ShortUrlDetailLink from './ShortUrlDetailLink'; import ShortUrlDetailLink from './ShortUrlDetailLink';
import './ShortUrlsRowMenu.scss';
export interface ShortUrlsRowMenuProps { export interface ShortUrlsRowMenuProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
@@ -45,7 +44,7 @@ const ShortUrlsRowMenu = (
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}> <DropdownItem className="dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL <FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</DropdownItem> </DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} /> <DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />

View File

@@ -0,0 +1,31 @@
import { RouteChildrenProps } from 'react-router-dom';
import { useMemo } from 'react';
import { isEmpty } from 'ramda';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
type ToFirstPage = (extra: Partial<ShortUrlsQuery>) => void;
export interface ShortUrlListRouteParams {
page: string;
serverId: string;
}
interface ShortUrlsQuery {
tags?: string;
search?: string;
startDate?: string;
endDate?: string;
}
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => {
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => {
const evolvedQuery = stringifyQuery({ ...query, ...extra });
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
};
return [ query, toFirstPageWithExtra ];
};

View File

@@ -49,7 +49,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
const result = await createShortUrl(data); const result = await createShortUrl(data);
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result }); dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e; throw e;

View File

@@ -48,7 +48,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
try { try {
await deleteShortUrl(shortCode, domain); await deleteShortUrl(shortCode, domain);
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain }); dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e; throw e;

View File

@@ -50,7 +50,7 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain); ) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL }); dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
} }
}; };

View File

@@ -55,7 +55,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
]); ]);
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED }); dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e; throw e;

View File

@@ -5,7 +5,7 @@ import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCr
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsResponse } from '../../api/types'; import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { ShortUrlsListParams } from './shortUrlsListParams'; import { ShortUrlsListParams } from './shortUrlsListParams';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
@@ -101,7 +101,7 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
}, initialState); }, initialState);
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
params: ShortUrlsListParams = {}, params: ShlinkShortUrlsListParams = {},
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: LIST_SHORT_URLS_START }); dispatch({ type: LIST_SHORT_URLS_START });
const { listShortUrls } = buildShlinkApiClient(getState); const { listShortUrls } = buildShlinkApiClient(getState);

View File

@@ -1,5 +1,5 @@
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { OrderDir } from '../../utils/utils'; import { OrderDir } from '../../utils/helpers/ordering';
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList'; import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
@@ -14,14 +14,12 @@ export const SORTABLE_FIELDS = {
export type OrderableFields = keyof typeof SORTABLE_FIELDS; export type OrderableFields = keyof typeof SORTABLE_FIELDS;
export type OrderBy = Partial<Record<OrderableFields, OrderDir>>;
export interface ShortUrlsListParams { export interface ShortUrlsListParams {
page?: string; page?: string;
itemsPerPage?: number; itemsPerPage?: number;
tags?: string[]; orderBy?: OrderBy;
searchTerm?: string;
startDate?: string;
endDate?: string;
orderBy?: Partial<Record<OrderableFields, OrderDir>>;
} }
const initialState: ShortUrlsListParams = { const initialState: ShortUrlsListParams = {

View File

@@ -1,5 +1,4 @@
import Bottle from 'bottlejs'; import Bottle, { Decorator } from 'bottlejs';
import ShortUrls from '../ShortUrls';
import SearchBar from '../SearchBar'; import SearchBar from '../SearchBar';
import ShortUrlsList from '../ShortUrlsList'; import ShortUrlsList from '../ShortUrlsList';
import ShortUrlsRow from '../helpers/ShortUrlsRow'; import ShortUrlsRow from '../helpers/ShortUrlsRow';
@@ -19,14 +18,11 @@ import { ShortUrlForm } from '../ShortUrlForm';
import { EditShortUrl } from '../EditShortUrl'; import { EditShortUrl } from '../EditShortUrl';
import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import { getShortUrlDetail } from '../reducers/shortUrlDetail';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Components // Components
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList'); bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar');
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
bottle.decorator('ShortUrlsList', connect( bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ], [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ],
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ], [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
)); ));
@@ -56,7 +52,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services // Services
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator'); bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ])); bottle.decorator('SearchBar', withRouter);
// Actions // Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');

View File

@@ -6,14 +6,13 @@ import { Link } from 'react-router-dom';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import ColorGenerator from '../utils/services/ColorGenerator'; import ColorGenerator from '../utils/services/ColorGenerator';
import { isServerWithId, SelectedServer } from '../servers/data'; import { getServerId, SelectedServer } from '../servers/data';
import TagBullet from './helpers/TagBullet'; import TagBullet from './helpers/TagBullet';
import { TagModalProps, TagStats } from './data'; import { NormalizedTag, TagModalProps } from './data';
import './TagCard.scss'; import './TagCard.scss';
export interface TagCardProps { export interface TagCardProps {
tag: string; tag: NormalizedTag;
tagStats?: TagStats;
selectedServer: SelectedServer; selectedServer: SelectedServer;
displayed: boolean; displayed: boolean;
toggle: () => void; toggle: () => void;
@@ -25,12 +24,12 @@ const TagCard = (
DeleteTagConfirmModal: FC<TagModalProps>, DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => { ) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle(); const [ isEditModalOpen, toggleEdit ] = useToggle();
const [ hasTitle,, displayTitle ] = useToggle(); const [ hasTitle,, displayTitle ] = useToggle();
const titleRef = useRef<HTMLElement>(); const titleRef = useRef<HTMLElement>();
const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; const serverId = getServerId(selectedServer);
useEffect(() => { useEffect(() => {
if (isTruncated(titleRef.current)) { if (isTruncated(titleRef.current)) {
@@ -49,39 +48,37 @@ const TagCard = (
</Button> </Button>
<h5 <h5
className="tag-card__tag-title text-ellipsis" className="tag-card__tag-title text-ellipsis"
title={hasTitle ? tag : undefined} title={hasTitle ? tag.tag : undefined}
ref={(el) => { ref={(el) => {
titleRef.current = el ?? undefined; titleRef.current = el ?? undefined;
}} }}
> >
<TagBullet tag={tag} colorGenerator={colorGenerator} /> <TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span> <span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
</h5> </h5>
</CardHeader> </CardHeader>
{tagStats && ( <Collapse isOpen={displayed}>
<Collapse isOpen={displayed}> <CardBody className="tag-card__body">
<CardBody className="tag-card__body"> <Link
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`} className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
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="mr-2" />Short URLs</span>
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span> <b>{prettify(tag.shortUrls)}</b>
<b>{prettify(tagStats.shortUrlsCount)}</b> </Link>
</Link> <Link
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}
to={`/server/${serverId}/tag/${tag}/visits`} className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center" >
> <span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span> <b>{prettify(tag.visits)}</b>
<b>{prettify(tagStats.visitsCount)}</b> </Link>
</Link> </CardBody>
</CardBody> </Collapse>
</Collapse>
)}
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} /> <DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} /> <EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
</Card> </Card>
); );
}; };

View File

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

View File

@@ -1,5 +1,6 @@
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { Row } from 'reactstrap'; import { Row } from 'reactstrap';
import { pipe } from 'ramda';
import Message from '../utils/Message'; import Message from '../utils/Message';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
@@ -8,9 +9,13 @@ import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { Settings, TagsMode } from '../settings/reducers/settings'; import { Settings, TagsMode } from '../settings/reducers/settings';
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
import SortingDropdown from '../utils/SortingDropdown';
import { TagsList as TagsListState } from './reducers/tagsList'; import { TagsList as TagsListState } from './reducers/tagsList';
import { TagsListChildrenProps } from './data/TagsListChildrenProps'; import { OrderableFields, SORTABLE_FIELDS, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
import { TagsModeDropdown } from './TagsModeDropdown'; import { TagsModeDropdown } from './TagsModeDropdown';
import { NormalizedTag } from './data';
import { TagsTableProps } from './TagsTable';
export interface TagsListProps { export interface TagsListProps {
filterTags: (searchTerm: string) => void; filterTags: (searchTerm: string) => void;
@@ -20,10 +25,19 @@ export interface TagsListProps {
settings: Settings; settings: Settings;
} }
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub(( const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps, { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => { ) => {
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards'); const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
const [ order, setOrder ] = useState<TagsOrder>({});
const resolveSortedTags = pipe(
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
tag,
shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0,
visits: tagsList.stats[tag]?.visitsCount ?? 0,
})),
(normalizedTags) => sortList<NormalizedTag>(normalizedTags, order),
);
useEffect(() => { useEffect(() => {
forceListTags(); forceListTags();
@@ -33,31 +47,49 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListCh
return <Message loading />; return <Message loading />;
} }
const renderContent = () => { if (tagsList.error) {
if (tagsList.error) { return (
return ( <Result type="error">
<Result type="error"> <ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" /> </Result>
</Result> );
); }
}
const orderByColumn = (field: OrderableFields) => () => {
const dir = determineOrderDir(field, order.field, order.dir);
setOrder({ field: dir ? field : undefined, dir });
};
const renderContent = () => {
if (tagsList.filteredTags.length < 1) { if (tagsList.filteredTags.length < 1) {
return <Message>No tags found</Message>; return <Message>No tags found</Message>;
} }
const sortedTags = resolveSortedTags();
return mode === 'cards' return mode === 'cards'
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} /> ? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />; : (
<TagsTable
sortedTags={sortedTags}
selectedServer={selectedServer}
currentOrder={order}
orderByColumn={orderByColumn}
/>
);
}; };
return ( return (
<> <>
<SearchField className="mb-3" onChange={filterTags} /> <SearchField className="mb-3" onChange={filterTags} />
<Row className="mb-3"> <Row className="mb-3">
<div className="col-lg-6 offset-lg-6"> <div className="col-lg-6">
<TagsModeDropdown mode={mode} onChange={setMode} /> <TagsModeDropdown mode={mode} onChange={setMode} />
</div> </div>
<div className="col-lg-6 mt-3 mt-lg-0">
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={(field, dir) => setOrder({ field, dir })} />
</div>
</Row> </Row>
{renderContent()} {renderContent()}
</> </>

10
src/tags/TagsTable.scss Normal file
View File

@@ -0,0 +1,10 @@
@import '../utils/base';
@import '../utils/mixins/sticky-cell';
.tags-table__header-cell.tags-table__header-cell {
@include sticky-cell(false);
top: $headerHeight;
position: sticky;
cursor: pointer;
}

View File

@@ -2,22 +2,27 @@ import { FC, useEffect, useRef } from 'react';
import { splitEvery } from 'ramda'; import { splitEvery } from 'ramda';
import { RouteChildrenProps } from 'react-router'; import { RouteChildrenProps } from 'react-router';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import ColorGenerator from '../utils/services/ColorGenerator';
import SimplePaginator from '../common/SimplePaginator'; import SimplePaginator from '../common/SimplePaginator';
import { useQueryState } from '../utils/helpers/hooks'; import { useQueryState } from '../utils/helpers/hooks';
import { parseQuery } from '../utils/helpers/query'; import { parseQuery } from '../utils/helpers/query';
import { TagsListChildrenProps } from './data/TagsListChildrenProps'; import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
import { TagsTableRowProps } from './TagsTableRow'; import { TagsTableRowProps } from './TagsTableRow';
import './TagsTable.scss';
export interface TagsTableProps extends TagsListChildrenProps {
orderByColumn: (field: OrderableFields) => () => void;
currentOrder: TagsOrder;
}
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsTableRowProps>) => ( export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps, { sortedTags, selectedServer, location, orderByColumn, currentOrder }: TagsTableProps & RouteChildrenProps,
) => { ) => {
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search); const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery)); const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
const sortedTags = tagsList.filteredTags; // TODO Support sorting tags
const pages = splitEvery(TAGS_PER_PAGE, sortedTags); const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
const showPaginator = pages.length > 1; const showPaginator = pages.length > 1;
const currentPage = pages[page - 1] ?? []; const currentPage = pages[page - 1] ?? [];
@@ -25,7 +30,7 @@ export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsT
useEffect(() => { useEffect(() => {
!isFirstLoad.current && setPage(1); !isFirstLoad.current && setPage(1);
isFirstLoad.current = false; isFirstLoad.current = false;
}, [ tagsList.filteredTags ]); }, [ sortedTags ]);
useEffect(() => { useEffect(() => {
scrollTo(0, 0); scrollTo(0, 0);
}, [ page ]); }, [ page ]);
@@ -35,23 +40,22 @@ export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsT
<table className="table table-hover mb-0"> <table className="table table-hover mb-0">
<thead className="responsive-table__header"> <thead className="responsive-table__header">
<tr> <tr>
<th>Tag</th> <th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
<th className="text-lg-right">Short URLs</th> Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
<th className="text-lg-right">Visits</th> </th>
<th /> <th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
</th>
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
</th>
<th className="tags-table__header-cell" />
</tr> </tr>
<tr><th colSpan={4} className="p-0 border-top-0" /></tr>
</thead> </thead>
<tbody> <tbody>
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>} {currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
{currentPage.map((tag) => ( {currentPage.map((tag) => <TagsTableRow key={tag.tag} tag={tag} selectedServer={selectedServer} />)}
<TagsTableRow
key={tag}
tag={tag}
tagStats={tagsList.stats[tag]}
selectedServer={selectedServer}
colorGenerator={colorGenerator}
/>
))}
</tbody> </tbody>
</table> </table>

View File

@@ -9,18 +9,18 @@ import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu'; import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import TagBullet from './helpers/TagBullet'; import TagBullet from './helpers/TagBullet';
import { TagModalProps, TagStats } from './data'; import { NormalizedTag, TagModalProps } from './data';
export interface TagsTableRowProps { export interface TagsTableRowProps {
tag: string; tag: NormalizedTag;
tagStats?: TagStats;
selectedServer: SelectedServer; selectedServer: SelectedServer;
colorGenerator: ColorGenerator;
} }
export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>) => ( export const TagsTableRow = (
{ tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps, DeleteTagConfirmModal: FC<TagModalProps>,
) => { EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator,
) => ({ tag, selectedServer }: TagsTableRowProps) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle(); const [ isEditModalOpen, toggleEdit ] = useToggle();
const [ isDropdownOpen, toggleDropdown ] = useToggle(); const [ isDropdownOpen, toggleDropdown ] = useToggle();
@@ -29,16 +29,16 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagMo
return ( return (
<tr className="responsive-table__row"> <tr className="responsive-table__row">
<th className="responsive-table__cell" data-th="Tag"> <th className="responsive-table__cell" data-th="Tag">
<TagBullet tag={tag} colorGenerator={colorGenerator} /> {tag} <TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
</th> </th>
<td className="responsive-table__cell text-lg-right" data-th="Short URLs"> <td className="responsive-table__cell text-lg-right" data-th="Short URLs">
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}> <Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
{prettify(tagStats?.shortUrlsCount ?? 0)} {prettify(tag.shortUrls)}
</Link> </Link>
</td> </td>
<td className="responsive-table__cell text-lg-right" data-th="Visits"> <td className="responsive-table__cell text-lg-right" data-th="Visits">
<Link to={`/server/${serverId}/tag/${tag}/visits`}> <Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
{prettify(tagStats?.visitsCount ?? 0)} {prettify(tag.visits)}
</Link> </Link>
</td> </td>
<td className="responsive-table__cell text-lg-right"> <td className="responsive-table__cell text-lg-right">
@@ -52,8 +52,8 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagMo
</DropdownBtnMenu> </DropdownBtnMenu>
</td> </td>
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} /> <EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} /> <DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
</tr> </tr>
); );
}; };

View File

@@ -1,7 +1,18 @@
import { TagsList as TagsListState } from '../reducers/tagsList';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { Order } from '../../utils/helpers/ordering';
import { NormalizedTag } from './index';
export const SORTABLE_FIELDS = {
tag: 'Tag',
shortUrls: 'Short URLs',
visits: 'Visits',
};
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
export type TagsOrder = Order<OrderableFields>;
export interface TagsListChildrenProps { export interface TagsListChildrenProps {
tagsList: TagsListState; sortedTags: NormalizedTag[];
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }

View File

@@ -8,3 +8,9 @@ export interface TagModalProps {
isOpen: boolean; isOpen: boolean;
toggle: () => void; toggle: () => void;
} }
export interface NormalizedTag {
tag: string;
shortUrls: number;
visits: number;
}

View File

@@ -44,7 +44,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
try { try {
await deleteTags([ tag ]); await deleteTags([ tag ]);
dispatch({ type: DELETE_TAG }); dispatch({ type: DELETE_TAG });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
throw e; throw e;

View File

@@ -59,7 +59,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
await editTag(oldName, newName); await editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color); colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName }); dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
throw e; throw e;

View File

@@ -126,7 +126,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
}, {}); }, {});
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS }); dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
} }
}; };

View File

@@ -27,9 +27,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ])); bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard'); bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal'); bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow'); bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
bottle.decorator('TagsTable', withRouter); bottle.decorator('TagsTable', withRouter);
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable'); bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');

View File

@@ -1,6 +1,7 @@
import { FC, useRef } from 'react'; import { FC, useRef } from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { InputType } from 'reactstrap/lib/Input'; import { InputType } from 'reactstrap/lib/Input';
import { FormGroup } from 'reactstrap';
export interface FormGroupContainerProps { export interface FormGroupContainerProps {
value: string; value: string;
@@ -19,7 +20,7 @@ export const FormGroupContainer: FC<FormGroupContainerProps> = (
const forId = useRef<string>(id ?? uuid()); const forId = useRef<string>(id ?? uuid());
return ( return (
<div className={`form-group ${className ?? ''}`}> <FormGroup className={className ?? ''}>
<label htmlFor={forId.current} className={labelClassName ?? ''}> <label htmlFor={forId.current} className={labelClassName ?? ''}>
{children}: {children}:
</label> </label>
@@ -32,6 +33,6 @@ export const FormGroupContainer: FC<FormGroupContainerProps> = (
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
</div> </FormGroup>
); );
}; };

View File

@@ -12,10 +12,11 @@ interface SearchFieldProps {
className?: string; className?: string;
large?: boolean; large?: boolean;
noBorder?: boolean; noBorder?: boolean;
initialValue?: string;
} }
const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => { const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
const [ searchTerm, setSearchTerm ] = useState(''); const [ searchTerm, setSearchTerm ] = useState(initialValue);
const resetTimer = () => { const resetTimer = () => {
timer && clearTimeout(timer); timer && clearTimeout(timer);

View File

@@ -3,23 +3,22 @@ import { toPairs } from 'ramda';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons'; import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { determineOrderDir, OrderDir } from './utils'; import { determineOrderDir, Order, OrderDir } from './helpers/ordering';
import './SortingDropdown.scss'; import './SortingDropdown.scss';
export interface SortingDropdownProps<T extends string = string> { export interface SortingDropdownProps<T extends string = string> {
items: Record<T, string>; items: Record<T, string>;
orderField?: T; order: Order<T>;
orderDir?: OrderDir;
onChange: (orderField?: T, orderDir?: OrderDir) => void; onChange: (orderField?: T, orderDir?: OrderDir) => void;
isButton?: boolean; isButton?: boolean;
right?: boolean; right?: boolean;
} }
export default function SortingDropdown<T extends string = string>( export default function SortingDropdown<T extends string = string>(
{ items, orderField, orderDir, onChange, isButton = true, right = false }: SortingDropdownProps<T>, { items, order, onChange, isButton = true, right = false }: SortingDropdownProps<T>,
) { ) {
const handleItemClick = (fieldKey: T) => () => { const handleItemClick = (fieldKey: T) => () => {
const newOrderDir = determineOrderDir(fieldKey, orderField, orderDir); const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
onChange(newOrderDir ? fieldKey : undefined, newOrderDir); onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
}; };
@@ -32,26 +31,26 @@ export default function SortingDropdown<T extends string = string>(
className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })} className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })}
> >
{!isButton && <>Order by</>} {!isButton && <>Order by</>}
{isButton && !orderField && <>Order by...</>} {isButton && !order.field && <>Order by...</>}
{isButton && orderField && `Order by: "${items[orderField]}" - "${orderDir ?? 'DESC'}"`} {isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`}
</DropdownToggle> </DropdownToggle>
<DropdownMenu <DropdownMenu
right={right} right={right}
className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })} className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
> >
{toPairs(items).map(([ fieldKey, fieldValue ]) => ( {toPairs(items).map(([ fieldKey, fieldValue ]) => (
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}> <DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
{fieldValue} {fieldValue}
{orderField === fieldKey && ( {order.field === fieldKey && (
<FontAwesomeIcon <FontAwesomeIcon
icon={orderDir === 'ASC' ? sortAscIcon : sortDescIcon} icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
className="sorting-dropdown__sort-icon" className="sorting-dropdown__sort-icon"
/> />
)} )}
</DropdownItem> </DropdownItem>
))} ))}
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem disabled={!orderField} onClick={() => onChange()}> <DropdownItem disabled={!order.field} onClick={() => onChange()}>
<i>Clear selection</i> <i>Clear selection</i>
</DropdownItem> </DropdownItem>
</DropdownMenu> </DropdownMenu>

View File

@@ -4,11 +4,16 @@ import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from './types';
export interface DateIntervalDropdownProps { export interface DateIntervalDropdownProps {
active?: DateInterval; active?: DateInterval;
allText: string;
onChange: (interval: DateInterval) => void; onChange: (interval: DateInterval) => void;
} }
export const DateIntervalDropdownItems: FC<DateIntervalDropdownProps> = ({ active, onChange }) => ( export const DateIntervalDropdownItems: FC<DateIntervalDropdownProps> = ({ active, allText, onChange }) => (
<> <>
<DropdownItem active={active === 'all'} onClick={() => onChange('all')}>
{allText}
</DropdownItem>
<DropdownItem divider />
{DATE_INTERVALS.map( {DATE_INTERVALS.map(
(interval) => ( (interval) => (
<DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval)}> <DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval)}>

View File

@@ -3,8 +3,8 @@ import { DropdownBtn } from '../DropdownBtn';
import { rangeOrIntervalToString } from './types'; import { rangeOrIntervalToString } from './types';
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems'; import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active }) => ( export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active, allText }) => (
<DropdownBtn text={rangeOrIntervalToString(active) ?? ''}> <DropdownBtn text={rangeOrIntervalToString(active) ?? allText}>
<DateIntervalDropdownItems active={active} onChange={onChange} /> <DateIntervalDropdownItems allText={allText} active={active} onChange={onChange} />
</DropdownBtn> </DropdownBtn>
); );

View File

@@ -4,10 +4,10 @@ import { DropdownBtn } from '../DropdownBtn';
import { import {
DateInterval, DateInterval,
DateRange, DateRange,
dateRangeIsEmpty,
rangeOrIntervalToString, rangeOrIntervalToString,
intervalToDateRange, intervalToDateRange,
rangeIsInterval, rangeIsInterval,
dateRangeIsEmpty,
} from './types'; } from './types';
import DateRangeRow from './DateRangeRow'; import DateRangeRow from './DateRangeRow';
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems'; import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
@@ -22,18 +22,16 @@ export interface DateRangeSelectorProps {
export const DateRangeSelector = ( export const DateRangeSelector = (
{ onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps, { onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps,
) => { ) => {
const [ activeInterval, setActiveInterval ] = useState( const initialIntervalIsRange = rangeIsInterval(initialDateRange);
rangeIsInterval(initialDateRange) ? initialDateRange : undefined, const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined);
); const [ activeDateRange, setActiveDateRange ] = useState(initialIntervalIsRange ? undefined : initialDateRange);
const [ activeDateRange, setActiveDateRange ] = useState(
!rangeIsInterval(initialDateRange) ? initialDateRange : undefined,
);
const updateDateRange = (dateRange: DateRange) => { const updateDateRange = (dateRange: DateRange) => {
setActiveInterval(undefined); setActiveInterval(dateRangeIsEmpty(dateRange) ? 'all' : undefined);
setActiveDateRange(dateRange); setActiveDateRange(dateRange);
onDatesChange(dateRange); onDatesChange(dateRange);
}; };
const updateInterval = (dateInterval?: DateInterval) => () => { const updateInterval = (dateInterval: DateInterval) => {
setActiveInterval(dateInterval); setActiveInterval(dateInterval);
setActiveDateRange(undefined); setActiveDateRange(undefined);
onDatesChange(intervalToDateRange(dateInterval)); onDatesChange(intervalToDateRange(dateInterval));
@@ -41,14 +39,7 @@ export const DateRangeSelector = (
return ( return (
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}> <DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
<DropdownItem <DateIntervalDropdownItems allText={defaultText} active={activeInterval} onChange={updateInterval} />
active={activeInterval === undefined && dateRangeIsEmpty(activeDateRange)}
onClick={updateInterval(undefined)}
>
{defaultText}
</DropdownItem>
<DropdownItem divider />
<DateIntervalDropdownItems active={activeInterval} onChange={(interval) => updateInterval(interval)()} />
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem header>Custom:</DropdownItem> <DropdownItem header>Custom:</DropdownItem>
<DropdownItem text> <DropdownItem text>

View File

@@ -7,14 +7,15 @@ export interface DateRange {
endDate?: Date | null; endDate?: Date | null;
} }
export type DateInterval = 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days'; export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days';
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|| isEmpty(filter(Boolean, dateRange as any)); || isEmpty(filter(Boolean, dateRange as any));
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval => typeof range === 'string'; export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
typeof range === 'string';
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string> = { const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
today: 'Today', today: 'Today',
yesterday: 'Yesterday', yesterday: 'Yesterday',
last7Days: 'Last 7 days', last7Days: 'Last 7 days',
@@ -22,9 +23,10 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string> = {
last90Days: 'Last 90 days', last90Days: 'Last 90 days',
last180days: 'Last 180 days', last180days: 'Last 180 days',
last365Days: 'Last 365 days', last365Days: 'Last 365 days',
all: undefined,
}; };
export const DATE_INTERVALS: DateInterval[] = Object.keys(INTERVAL_TO_STRING_MAP) as DateInterval[]; export const DATE_INTERVALS = Object.keys(INTERVAL_TO_STRING_MAP).filter((value) => value !== 'all') as DateInterval[];
const dateRangeToString = (range?: DateRange): string | undefined => { const dateRangeToString = (range?: DateRange): string | undefined => {
if (!range || dateRangeIsEmpty(range)) { if (!range || dateRangeIsEmpty(range)) {
@@ -43,7 +45,7 @@ const dateRangeToString = (range?: DateRange): string | undefined => {
}; };
export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => { export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
if (!range) { if (!range || range === 'all') {
return undefined; return undefined;
} }
@@ -58,7 +60,7 @@ const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(new Date(), daysA
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) }); const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => { export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
if (!dateInterval) { if (!dateInterval || dateInterval === 'all') {
return {}; return {};
} }

View File

@@ -1,4 +1,4 @@
import { format, formatISO, parse } from 'date-fns'; import { format, formatISO, isAfter, isBefore, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
import { OptionalString } from '../utils'; import { OptionalString } from '../utils';
type DateOrString = Date | string; type DateOrString = Date | string;
@@ -21,3 +21,21 @@ export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date,
export const formatInternational = formatDate(); export const formatInternational = formatDate();
export const parseDate = (date: string, format: string) => parse(date, format, new Date()); export const parseDate = (date: string, format: string) => parse(date, format, new Date());
const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date);
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
if (!start && end) {
return isBefore(parseISO(date), parseISO(end));
}
if (start && !end) {
return isAfter(parseISO(date), parseISO(start));
}
if (start && end) {
return isWithinInterval(parseISO(date), { start: parseISO(start), end: parseISO(end) });
}
return true;
};

View File

@@ -21,3 +21,5 @@ export const supportsCrawlableVisits = supportsBotVisits;
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' }); export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
export const supportsDomainRedirects = supportsQrErrorCorrection; export const supportsDomainRedirects = supportsQrErrorCorrection;
export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' });

View File

@@ -0,0 +1,32 @@
export type OrderDir = 'ASC' | 'DESC' | undefined;
export interface Order<Fields> {
field?: Fields;
dir?: OrderDir;
}
export const determineOrderDir = <T extends string = string>(
currentField: T,
newField?: T,
currentOrderDir?: OrderDir,
): OrderDir => {
if (currentField !== newField) {
return 'ASC';
}
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
ASC: 'DESC',
DESC: undefined,
};
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
};
export const sortList = <List>(list: List[], { field, dir }: Order<Partial<keyof List>>) => !field || !dir
? list
: list.sort((a, b) => {
const greaterThan = dir === 'ASC' ? 1 : -1;
const smallerThan = dir === 'ASC' ? -1 : 1;
return a[field] > b[field] ? greaterThan : smallerThan;
});

View File

@@ -1,6 +1,6 @@
@import '../base'; @import '../base';
@mixin sticky-cell() { @mixin sticky-cell($with-separators: true) {
z-index: 1; z-index: 1;
border: none !important; border: none !important;
position: relative; position: relative;
@@ -11,20 +11,20 @@
top: -1px; top: -1px;
left: 0; left: 0;
bottom: -1px; bottom: -1px;
right: -1px; right: if($with-separators, -1px, 0);
background: var(--table-border-color); background: var(--table-border-color);
z-index: -2; z-index: -2;
} }
&:first-child:before { &:first-child:before {
left: -1px; left: if($with-separators, -1px, 0);
} }
&:after { &:after {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 1px; left: if($with-separators, 1px, 0);
bottom: 0; bottom: 0;
right: 0; right: 0;
background: var(--primary-color); background: var(--primary-color);

View File

@@ -0,0 +1,19 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { Order } from '../helpers/ordering';
interface TableOrderIconProps<T> {
currentOrder: Order<T>;
field: T;
className?: string;
}
export function TableOrderIcon<T extends string = string>(
{ currentOrder, field, className = 'ml-1' }: TableOrderIconProps<T>,
) {
if (!currentOrder.dir || currentOrder.field !== field) {
return null;
}
return <FontAwesomeIcon icon={currentOrder.dir === 'ASC' ? caretUpIcon : caretDownIcon} className={className} />;
}

View File

@@ -1,25 +1,6 @@
import { isEmpty, isNil, pipe, range } from 'ramda'; import { isEmpty, isNil, pipe, range } from 'ramda';
import { SyntheticEvent } from 'react'; import { SyntheticEvent } from 'react';
export type OrderDir = 'ASC' | 'DESC' | undefined;
export const determineOrderDir = <T extends string = string>(
currentField: T,
newField?: T,
currentOrderDir?: OrderDir,
): OrderDir => {
if (currentField !== newField) {
return 'ASC';
}
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
ASC: 'DESC',
DESC: undefined,
};
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
};
export const rangeOf = <T>(size: number, mappingFn: (value: number) => T, startAt = 1): T[] => export const rangeOf = <T>(size: number, mappingFn: (value: number) => T, startAt = 1): T[] =>
range(startAt, size + 1).map(mappingFn); range(startAt, size + 1).map(mappingFn);

View File

@@ -15,6 +15,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features'; import { supportsBotVisits } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers';
import LineChartCard from './charts/LineChartCard'; import LineChartCard from './charts/LineChartCard';
import VisitsTable from './VisitsTable'; import VisitsTable from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types'; import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
@@ -295,7 +296,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
className="btn-md-block mr-2" className="btn-md-block mr-2"
onClick={() => setSelectedVisits([])} onClick={() => setSelectedVisits([])}
> >
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>} Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
</Button> </Button>
<Button <Button
outline outline
@@ -303,7 +304,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
className="btn-md-block" className="btn-md-block"
onClick={() => exportCsv(normalizedVisits)} onClick={() => exportCsv(normalizedVisits)}
> >
<FontAwesomeIcon icon={faFileDownload} /> Export ({normalizedVisits.length}) <FontAwesomeIcon icon={faFileDownload} /> Export ({prettify(normalizedVisits.length)})
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,21 +1,17 @@
import { useEffect, useMemo, useState, useRef } from 'react'; import { useEffect, useMemo, useState, useRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { min, splitEvery } from 'ramda'; import { min, splitEvery } from 'ramda';
import { import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons';
faCaretDown as caretDownIcon,
faCaretUp as caretUpIcon,
faCheck as checkIcon,
faRobot as botIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import SimplePaginator from '../common/SimplePaginator'; import SimplePaginator from '../common/SimplePaginator';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { determineOrderDir, OrderDir } from '../utils/utils'; import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { supportsBotVisits } from '../utils/helpers/features'; import { supportsBotVisits } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { Time } from '../utils/Time'; import { Time } from '../utils/Time';
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import { NormalizedOrphanVisit, NormalizedVisit } from './types';
import './VisitsTable.scss'; import './VisitsTable.scss';
@@ -29,11 +25,7 @@ export interface VisitsTableProps {
} }
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot'; type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
type VisitsOrder = Order<OrderableFields>;
interface Order {
field?: OrderableFields;
dir?: OrderDir;
}
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) => const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
@@ -42,15 +34,8 @@ const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: No
); );
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) => const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
visits.filter((visit) => visitMatchesSearch(visit, searchTerm)); visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort( const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList<NormalizedVisit>(visits, order as any);
(a, b) => { const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => {
const greaterThan = dir === 'ASC' ? 1 : -1;
const smallerThan = dir === 'ASC' ? -1 : 1;
return (a as NormalizedOrphanVisit)[field] > (b as NormalizedOrphanVisit)[field] ? greaterThan : smallerThan;
},
);
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => {
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ]; const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
const sortedVisits = sortVisits(order, filteredVisits); const sortedVisits = sortVisits(order, filteredVisits);
const total = sortedVisits.length; const total = sortedVisits.length;
@@ -72,7 +57,7 @@ const VisitsTable = ({
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined); const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined);
const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined }); const [ order, setOrder ] = useState<VisitsOrder>({});
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
const [ page, setPage ] = useState(1); const [ page, setPage ] = useState(1);
@@ -83,12 +68,8 @@ const VisitsTable = ({
const orderByColumn = (field: OrderableFields) => const orderByColumn = (field: OrderableFields) =>
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field && ( const renderOrderIcon = (field: OrderableFields) =>
<FontAwesomeIcon <TableOrderIcon currentOrder={order} field={field} className="visits-table__header-icon" />;
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
className="visits-table__header-icon"
/>
);
useEffect(() => { useEffect(() => {
const listener = () => setIsMobileDevice(matchMobile()); const listener = () => setIsMobileDevice(matchMobile());

View File

@@ -1,6 +1,7 @@
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda'; import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import { OrderDir, rangeOf } from '../../utils/utils'; import { rangeOf } from '../../utils/utils';
import { Order } from '../../utils/helpers/ordering';
import SimplePaginator from '../../common/SimplePaginator'; import SimplePaginator from '../../common/SimplePaginator';
import { roundTen } from '../../utils/helpers/numbers'; import { roundTen } from '../../utils/helpers/numbers';
import SortingDropdown from '../../utils/SortingDropdown'; import SortingDropdown from '../../utils/SortingDropdown';
@@ -29,24 +30,21 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
withPagination = true, withPagination = true,
...rest ...rest
}) => { }) => {
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({ const [ order, setOrder ] = useState<Order<string>>({});
orderField: undefined,
orderDir: undefined,
});
const [ currentPage, setCurrentPage ] = useState(1); const [ currentPage, setCurrentPage ] = useState(1);
const [ itemsPerPage, setItemsPerPage ] = useState(50); const [ itemsPerPage, setItemsPerPage ] = useState(50);
const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => { const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => {
const pairs = toPairs(stats); const pairs = toPairs(stats);
const sortedPairs = !order.orderField ? pairs : sortBy( const sortedPairs = !order.field ? pairs : sortBy(
pipe<StatsRow, string | number, string | number>( pipe<StatsRow, string | number, string | number>(
order.orderField === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair, order.field === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
toLowerIfString, toLowerIfString,
), ),
pairs, pairs,
); );
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs); return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
}; };
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => { const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
const page = pages[currentPage - 1]; const page = pages[currentPage - 1];
@@ -102,10 +100,9 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
isButton={false} isButton={false}
right right
items={sortingItems} items={sortingItems}
orderField={order.orderField} order={order}
orderDir={order.orderDir} onChange={(field, dir) => {
onChange={(orderField, orderDir) => { setOrder({ field, dir });
setOrder({ orderField, orderDir });
setCurrentPage(1); setCurrentPage(1);
}} }}
/> />

View File

@@ -72,7 +72,7 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
const visits = await loadVisits(); const visits = await loadVisits();
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish }); dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
} catch (e) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
} }
}; };

View File

@@ -6,6 +6,7 @@ import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types'; import { ShlinkVisitsParams } from '../../api/types';
import { isOrphanVisit } from '../types/helpers'; import { isOrphanVisit } from '../types/helpers';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
@@ -20,6 +21,7 @@ export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHA
export interface OrphanVisitsAction extends Action<string> { export interface OrphanVisitsAction extends Action<string> {
visits: Visit[]; visits: Visit[];
query?: ShlinkVisitsParams;
} }
type OrphanVisitsCombinedAction = OrphanVisitsAction type OrphanVisitsCombinedAction = OrphanVisitsAction
@@ -39,13 +41,16 @@ const initialState: VisitsInfo = {
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({ export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_ORPHAN_VISITS]: (_, { visits }) => ({ ...initialState, visits }), [GET_ORPHAN_VISITS]: (_, { visits, query }) => ({ ...initialState, visits, query }),
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { visits } = state; const { visits, query = {} } = state;
const newVisits = createdVisits.map(({ visit }) => visit); const { startDate, endDate } = query;
const newVisits = createdVisits
.filter(({ visit }) => isBetween(visit.date, startDate, endDate))
.map(({ visit }) => visit);
return { ...state, visits: [ ...newVisits, ...visits ] }; return { ...state, visits: [ ...newVisits, ...visits ] };
}, },
@@ -66,6 +71,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
return { ...result, data: visits }; return { ...result, data: visits };
}); });
const shouldCancel = () => getState().orphanVisits.cancelLoad; const shouldCancel = () => getState().orphanVisits.cancelLoad;
const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
const actionMap = { const actionMap = {
start: GET_ORPHAN_VISITS_START, start: GET_ORPHAN_VISITS_START,
large: GET_ORPHAN_VISITS_LARGE, large: GET_ORPHAN_VISITS_LARGE,
@@ -74,7 +80,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED, progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
}; };
return getVisitsWithLoader(visitsLoader, {}, actionMap, dispatch, shouldCancel); return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
}; };
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL); export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);

View File

@@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types'; import { ShlinkVisitsParams } from '../../api/types';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
@@ -23,6 +24,7 @@ export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier { interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
visits: Visit[]; visits: Visit[];
query?: ShlinkVisitsParams;
} }
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
@@ -33,7 +35,7 @@ type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
const initialState: ShortUrlVisits = { const initialState: ShortUrlVisits = {
visits: [], visits: [],
shortCode: '', shortCode: '',
domain: undefined, domain: undefined, // Deprecated. Value from query params can be used instead
loading: false, loading: false,
loadingLarge: false, loadingLarge: false,
error: false, error: false,
@@ -44,22 +46,27 @@ const initialState: ShortUrlVisits = {
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_SHORT_URL_VISITS]: (_, { visits, shortCode, domain }) => ({ [GET_SHORT_URL_VISITS]: (_, { visits, query, shortCode, domain }) => ({
...initialState, ...initialState,
visits, visits,
shortCode, shortCode,
domain, domain,
query,
}), }),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { shortCode, domain, visits } = state; const { shortCode, domain, visits, query = {} } = state;
const { startDate, endDate } = query;
const newVisits = createdVisits const newVisits = createdVisits
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain)) .filter(
({ shortUrl, visit }) =>
shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
)
.map(({ visit }) => visit); .map(({ visit }) => visit);
return { ...state, visits: [ ...newVisits, ...visits ] }; return newVisits.length === 0 ? state : { ...state, visits: [ ...newVisits, ...visits ] };
}, },
}, initialState); }, initialState);
@@ -73,7 +80,7 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder)
{ ...query, page, itemsPerPage }, { ...query, page, itemsPerPage },
); );
const shouldCancel = () => getState().shortUrlVisits.cancelLoad; const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, domain: query.domain }; const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain };
const actionMap = { const actionMap = {
start: GET_SHORT_URL_VISITS_START, start: GET_SHORT_URL_VISITS_START,
large: GET_SHORT_URL_VISITS_LARGE, large: GET_SHORT_URL_VISITS_LARGE,

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