mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-28 04:36:45 +00:00
Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fcdcb049 | ||
|
|
5b7f1ef18a | ||
|
|
715128a653 | ||
|
|
83fbdbb135 | ||
|
|
2e963bdc8e | ||
|
|
8d6e93ea4f | ||
|
|
112a8cdf2f | ||
|
|
27476d8b23 | ||
|
|
2ad2d69b2b | ||
|
|
a3d6944fc1 | ||
|
|
552169ee77 | ||
|
|
4f03ab18e5 | ||
|
|
184d5d97e7 | ||
|
|
ba667a0768 | ||
|
|
15b3424d7f | ||
|
|
98398a048b | ||
|
|
3cb066f5f5 | ||
|
|
053b38bee3 | ||
|
|
1f9356cc21 | ||
|
|
f07e7fd31c | ||
|
|
7794876d7c | ||
|
|
e77b4d7a82 | ||
|
|
af0d2d3cdc | ||
|
|
7e132be686 | ||
|
|
aba1972d0d | ||
|
|
0268bb6930 | ||
|
|
ecd6e6a066 | ||
|
|
6411c6169b | ||
|
|
a78467065a | ||
|
|
c05c74f009 | ||
|
|
ace29ca4a4 | ||
|
|
4f90d147a4 | ||
|
|
9348f211f0 | ||
|
|
729d9e4a39 | ||
|
|
3274088b54 | ||
|
|
49c841ca07 | ||
|
|
91f319df65 | ||
|
|
dbf4b0926e | ||
|
|
994f31b7e5 | ||
|
|
6213067f35 | ||
|
|
76fb45c97e | ||
|
|
2bf5f276f5 | ||
|
|
eaadd6f7af | ||
|
|
86c6acb7b8 | ||
|
|
de32d899bc | ||
|
|
d4356ba6e6 | ||
|
|
275aee4de2 | ||
|
|
57075c581d | ||
|
|
d8442e435d | ||
|
|
e954a860bf | ||
|
|
5598fe0f53 | ||
|
|
e77508edcc | ||
|
|
c517c0521c | ||
|
|
e22856ff74 | ||
|
|
a30687e4ea | ||
|
|
64ba346566 | ||
|
|
3745b297db | ||
|
|
401418c049 | ||
|
|
7adb40489d | ||
|
|
482314b9f4 | ||
|
|
138e40315d | ||
|
|
7d6afd47b1 | ||
|
|
ed1f650fc6 | ||
|
|
17e4e06fcc | ||
|
|
654b36ab08 | ||
|
|
9abbfc5b1e | ||
|
|
c9d906316f | ||
|
|
8d476e0729 | ||
|
|
7a320c9574 | ||
|
|
3f1392ce62 | ||
|
|
79e54ea230 | ||
|
|
e2473207ba | ||
|
|
fb961dd47b | ||
|
|
ff1821666e | ||
|
|
9a62bcd8fb | ||
|
|
9c6c1b43c8 | ||
|
|
4986dbcb91 | ||
|
|
527d4acf17 | ||
|
|
0237253caf | ||
|
|
47f5f47867 | ||
|
|
70d4572797 | ||
|
|
8bfa14386b | ||
|
|
9f6401c30b | ||
|
|
14b2ee53b5 | ||
|
|
7db9974e8d | ||
|
|
7d29129ca1 | ||
|
|
42152c6872 | ||
|
|
b7e9afd54a | ||
|
|
3bc9bd2ef8 | ||
|
|
7bc3819ebe | ||
|
|
0642443aa9 | ||
|
|
2e77cd1969 | ||
|
|
21b8e05e35 | ||
|
|
ed038b9799 | ||
|
|
5f33059de1 | ||
|
|
3bc5b4c154 | ||
|
|
a2421ee2d3 | ||
|
|
109baef828 | ||
|
|
303900756d | ||
|
|
fe81e023e8 | ||
|
|
5906921eec | ||
|
|
ee826458be | ||
|
|
7169c6e083 | ||
|
|
0bb5c7d8af | ||
|
|
a6892b8a12 | ||
|
|
765c4713a2 | ||
|
|
e6737ff1f2 | ||
|
|
7a2d0e5dee | ||
|
|
daf076a57e | ||
|
|
af08b53002 | ||
|
|
39d5853fe3 | ||
|
|
9cbeef1cb4 | ||
|
|
2857e59273 | ||
|
|
04571ea634 | ||
|
|
5241925acc | ||
|
|
844cf51d04 | ||
|
|
b0c1549005 | ||
|
|
16d2e437b6 | ||
|
|
944b166e43 | ||
|
|
e5f99d0893 | ||
|
|
57e73dcba6 | ||
|
|
80f0f9bd08 | ||
|
|
1486d1fba5 | ||
|
|
e28f74169d | ||
|
|
2375882c73 | ||
|
|
7b344998ea | ||
|
|
e8ea3b4abe | ||
|
|
bd0fca23cf | ||
|
|
6d392ba403 | ||
|
|
e135dd92ec | ||
|
|
36af3c3dd0 | ||
|
|
c0e33d6a6a | ||
|
|
41398f659e | ||
|
|
8618519b6b | ||
|
|
c7c32b494e | ||
|
|
ec9fd67b8a | ||
|
|
7637ce3107 | ||
|
|
ada5488a6c | ||
|
|
478209f50d | ||
|
|
7f4263966e | ||
|
|
002f280364 | ||
|
|
d8a6676d30 | ||
|
|
beff6668de | ||
|
|
4baa901f1c | ||
|
|
29bf53bf88 | ||
|
|
d2284cd181 | ||
|
|
88305a57bf | ||
|
|
f4908cacc3 | ||
|
|
2925752fde | ||
|
|
1bf3569774 | ||
|
|
9e6907deb4 | ||
|
|
eaa6efe803 | ||
|
|
d38020e2d1 | ||
|
|
4c1d285d04 | ||
|
|
c71e0919e9 |
@@ -5,3 +5,4 @@
|
|||||||
./test
|
./test
|
||||||
./shlink-web-client.gif
|
./shlink-web-client.gif
|
||||||
./dist
|
./dist
|
||||||
|
./docs
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
.github/workflows/ci.yml
vendored
56
.github/workflows/ci.yml
vendored
@@ -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 .
|
|
||||||
|
|||||||
4
.github/workflows/deploy-preview.yml
vendored
4
.github/workflows/deploy-preview.yml
vendored
@@ -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 && \
|
||||||
|
|||||||
4
.github/workflows/publish-release.yml
vendored
4
.github/workflows/publish-release.yml
vendored
@@ -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
|
||||||
|
|||||||
112
CHANGELOG.md
112
CHANGELOG.md
@@ -4,6 +4,118 @@ 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.5.1] - 2022-01-08
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#555](https://github.com/shlinkio/shlink-web-client/issues/555) Fixed vertical alignment in welcome screen logo.
|
||||||
|
* [#554](https://github.com/shlinkio/shlink-web-client/issues/554) Fixed behavior in overview page, where items in the list of short URLs were stripped out when creating new ones, even if the amount of short URLs was still not yet big enough.
|
||||||
|
* [#557](https://github.com/shlinkio/shlink-web-client/issues/557) Fixed new tags added to new short URLs, not appearing on tags autosuggest.
|
||||||
|
|
||||||
|
|
||||||
|
## [3.5.0] - 2022-01-01
|
||||||
|
### Added
|
||||||
|
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
|
||||||
|
|
||||||
|
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
|
||||||
|
|
||||||
|
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
|
||||||
|
|
||||||
|
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
|
||||||
|
|
||||||
|
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
|
||||||
|
|
||||||
|
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
|
||||||
|
|
||||||
|
* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
|
||||||
|
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
|
||||||
|
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
|
||||||
|
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
|
||||||
|
* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
|
||||||
|
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
|
## [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*
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
|
[](https://twitter.com/shlinkio)
|
||||||
[](https://slnk.to/donate)
|
[](https://slnk.to/donate)
|
||||||
|
|
||||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
5
docs/adr/README.md
Normal 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)
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
42643
package-lock.json
generated
42643
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -27,7 +27,7 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.2",
|
||||||
"bootstrap": "^4.6.0",
|
"bootstrap": "^4.6.0",
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -12,30 +11,34 @@ import {
|
|||||||
ShlinkVisits,
|
ShlinkVisits,
|
||||||
ShlinkVisitsParams,
|
ShlinkVisitsParams,
|
||||||
ShlinkShortUrlData,
|
ShlinkShortUrlData,
|
||||||
ShlinkDomain,
|
|
||||||
ShlinkDomainsResponse,
|
ShlinkDomainsResponse,
|
||||||
ShlinkVisitsOverview,
|
ShlinkVisitsOverview,
|
||||||
ShlinkEditDomainRedirects,
|
ShlinkEditDomainRedirects,
|
||||||
ShlinkDomainRedirects,
|
ShlinkDomainRedirects,
|
||||||
|
ShlinkShortUrlsListParams,
|
||||||
|
ShlinkShortUrlsListNormalizedParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { stringifyQuery } from '../../utils/helpers/query';
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { orderToString } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : '';
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
|
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||||
|
const { orderBy = {}, ...rest } = params;
|
||||||
|
|
||||||
|
return { ...rest, orderBy: orderToString(orderBy) };
|
||||||
|
};
|
||||||
|
|
||||||
export default class ShlinkApiClient {
|
export default class ShlinkApiClient {
|
||||||
private apiVersion: number;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly axios: AxiosInstance,
|
private readonly axios: AxiosInstance,
|
||||||
private readonly baseUrl: string,
|
private readonly baseUrl: string,
|
||||||
private readonly apiKey: string,
|
private readonly apiKey: string,
|
||||||
) {
|
) {
|
||||||
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', normalizeOrderByInParams(params))
|
||||||
.then(({ data }) => data.shortUrls);
|
.then(({ data }) => data.shortUrls);
|
||||||
|
|
||||||
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
||||||
@@ -69,7 +72,10 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||||
.then(() => {});
|
.then(() => {});
|
||||||
|
|
||||||
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */
|
// eslint-disable-next-line valid-jsdoc
|
||||||
|
/**
|
||||||
|
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
|
||||||
|
*/
|
||||||
public readonly updateShortUrlTags = async (
|
public readonly updateShortUrlTags = async (
|
||||||
shortCode: string,
|
shortCode: string,
|
||||||
domain: OptionalString,
|
domain: OptionalString,
|
||||||
@@ -107,43 +113,21 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||||
.then((resp) => resp.data);
|
.then((resp) => resp.data);
|
||||||
|
|
||||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
||||||
|
|
||||||
public readonly editDomainRedirects = async (
|
public readonly editDomainRedirects = async (
|
||||||
domainRedirects: ShlinkEditDomainRedirects,
|
domainRedirects: ShlinkEditDomainRedirects,
|
||||||
): Promise<ShlinkDomainRedirects> =>
|
): Promise<ShlinkDomainRedirects> =>
|
||||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
|
||||||
try {
|
this.axios({
|
||||||
return await this.axios({
|
method,
|
||||||
method,
|
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
|
||||||
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
params: rejectNilProps(query),
|
||||||
params: rejectNilProps(query),
|
data: body,
|
||||||
data: body,
|
paramsSerializer: stringifyQuery,
|
||||||
paramsSerializer: stringifyQuery,
|
});
|
||||||
});
|
|
||||||
} catch (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
|
|
||||||
// when performed from the browser (due to the preflight request not returning a 2xx status.
|
|
||||||
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
|
|
||||||
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
|
|
||||||
// if a request has been performed to a not supported API version.
|
|
||||||
const apiVersionIsNotSupported = !response;
|
|
||||||
|
|
||||||
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
|
||||||
// caller handle it
|
|
||||||
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.apiVersion = this.apiVersion - 1;
|
|
||||||
|
|
||||||
return await this.performRequest(url, method, query, body);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, ShortUrlsOrder } from '../../short-urls/data';
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
export interface ShlinkShortUrlsResponse {
|
||||||
data: ShortUrl[];
|
data: ShortUrl[];
|
||||||
@@ -25,12 +25,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 {
|
||||||
@@ -83,6 +83,21 @@ export interface ShlinkDomain {
|
|||||||
|
|
||||||
export interface ShlinkDomainsResponse {
|
export interface ShlinkDomainsResponse {
|
||||||
data: ShlinkDomain[];
|
data: ShlinkDomain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListParams {
|
||||||
|
page?: string;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
tags?: string[];
|
||||||
|
searchTerm?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orderBy?: ShortUrlsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||||
|
orderBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
export interface ProblemDetailsError {
|
||||||
@@ -90,7 +105,6 @@ export interface ProblemDetailsError {
|
|||||||
detail: string;
|
detail: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: number;
|
status: number;
|
||||||
|
|
||||||
[extraProps: string]: any;
|
[extraProps: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -11,8 +12,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home__logo-wrapper {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
.home__logo {
|
.home__logo {
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
|
|
||||||
|
width: calc(100% - 3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__main-card {
|
.home__main-card {
|
||||||
@@ -24,6 +33,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home__title-wrapper {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.home__title {
|
.home__title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
|
|||||||
@@ -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,25 +10,34 @@ 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">
|
||||||
<Row noGutters>
|
<Row noGutters>
|
||||||
<div className="col-md-5 d-none d-md-block">
|
<div className="col-md-5 d-none d-md-block">
|
||||||
<div className="p-4">
|
<div className="home__logo-wrapper">
|
||||||
<ShlinkLogo />
|
<div className="home__logo">
|
||||||
|
<ShlinkLogo />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-7 home__servers-container">
|
<div className="col-md-7 home__servers-container">
|
||||||
<div className="p-4">
|
<div className="home__title-wrapper">
|
||||||
<h1 className="home__title">Welcome!</h1>
|
<h1 className="home__title">Welcome!</h1>
|
||||||
</div>
|
</div>
|
||||||
<ServersListGroup embedded servers={serversList}>
|
<ServersListGroup embedded servers={serversList}>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import './NoMenuLayout.scss';
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
export const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
||||||
|
|
||||||
export default NoMenuLayout;
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { ConnectDecorator } from './types';
|
|||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
const { container } = bottle;
|
|
||||||
|
export const { container } = bottle;
|
||||||
|
|
||||||
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
|
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
|
||||||
(...args: any[]) => (container[serviceName] as T)(...args) as K;
|
(...args: any[]) => (container[serviceName] as T)(...args) as K;
|
||||||
@@ -33,10 +34,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);
|
||||||
@@ -44,5 +45,3 @@ provideUtilsServices(bottle);
|
|||||||
provideMercureServices(bottle);
|
provideMercureServices(bottle);
|
||||||
provideSettingsServices(bottle, connect);
|
provideSettingsServices(bottle, connect);
|
||||||
provideDomainsServices(bottle, connect);
|
provideDomainsServices(bottle, connect);
|
||||||
|
|
||||||
export default container;
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import ReduxThunk from 'redux-thunk';
|
|||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
import { applyMiddleware, compose, createStore } from 'redux';
|
||||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||||
import reducers from '../reducers';
|
import reducers from '../reducers';
|
||||||
|
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||||
|
import { ShlinkState } from './types';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV !== 'production';
|
const isProduction = process.env.NODE_ENV !== 'production';
|
||||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
@@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = {
|
|||||||
namespaceSeparator: '.',
|
namespaceSeparator: '.',
|
||||||
debounce: 300,
|
debounce: 300,
|
||||||
};
|
};
|
||||||
|
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||||
|
|
||||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
||||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||||
));
|
));
|
||||||
|
|
||||||
export default store;
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Settings } from '../settings/reducers/settings';
|
|||||||
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||||
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { TagDeletion } from '../tags/reducers/tagDelete';
|
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||||
import { TagEdition } from '../tags/reducers/tagEdit';
|
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||||
@@ -20,7 +19,6 @@ export interface ShlinkState {
|
|||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsList;
|
shortUrlsList: ShortUrlsList;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
|
||||||
shortUrlCreationResult: ShortUrlCreation;
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
shortUrlDeletion: ShortUrlDeletion;
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { FC } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faBan as forbiddenIcon,
|
faBan as forbiddenIcon,
|
||||||
faCheck as defaultDomainIcon,
|
faDotCircle as defaultDomainIcon,
|
||||||
faEdit as editIcon,
|
faEdit as editIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { OptionalString } from '../utils/utils';
|
import { OptionalString } from '../utils/utils';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
|
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
|
||||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||||
|
import { Domain } from './data';
|
||||||
|
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||||
|
|
||||||
interface DomainRowProps {
|
interface DomainRowProps {
|
||||||
domain: ShlinkDomain;
|
domain: Domain;
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
checkDomainHealth: (domain: string) => void;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||||
@@ -25,19 +31,25 @@ 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
|
export const DomainRow: FC<DomainRowProps> = (
|
||||||
|
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
||||||
|
) => {
|
||||||
const [ isOpen, toggle ] = useToggle();
|
const [ isOpen, toggle ] = useToggle();
|
||||||
const { domain: authority, isDefault, redirects } = domain;
|
const { domain: authority, isDefault, redirects, status } = domain;
|
||||||
const domainId = `domainEdit${authority.replace(/\./g, '')}`;
|
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkDomainHealth(domain.domain);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="responsive-table__row">
|
<tr className="responsive-table__row">
|
||||||
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
|
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
|
||||||
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||||
<td className="responsive-table__cell" data-th="Base path redirect">
|
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||||
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||||
@@ -48,14 +60,17 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
|
|||||||
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||||
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
||||||
|
<DomainStatusIcon status={status} />
|
||||||
|
</td>
|
||||||
<td className="responsive-table__cell text-right">
|
<td className="responsive-table__cell text-right">
|
||||||
<span id={domainId}>
|
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
||||||
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
||||||
<FontAwesomeIcon icon={isDefault ? forbiddenIcon : editIcon} />
|
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
{isDefault && (
|
{!canEditDomain && (
|
||||||
<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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
|||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { ShlinkDomainRedirects } from '../api/types';
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
import { DomainsList } from './reducers/domainsList';
|
import { DomainsList } from './reducers/domainsList';
|
||||||
import { DomainRow } from './DomainRow';
|
import { DomainRow } from './DomainRow';
|
||||||
|
|
||||||
@@ -12,16 +13,18 @@ interface ManageDomainsProps {
|
|||||||
listDomains: Function;
|
listDomains: Function;
|
||||||
filterDomains: (searchTerm: string) => void;
|
filterDomains: (searchTerm: string) => void;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
checkDomainHealth: (domain: string) => void;
|
||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ];
|
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
|
||||||
|
|
||||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
{ listDomains, domainsList, filterDomains, editDomainRedirects },
|
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
||||||
) => {
|
) => {
|
||||||
const { filteredDomains: domains, loading, error, errorData } = domainsList;
|
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||||
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
|
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listDomains();
|
listDomains();
|
||||||
@@ -53,7 +56,9 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
|||||||
key={domain.domain}
|
key={domain.domain}
|
||||||
domain={domain}
|
domain={domain}
|
||||||
editDomainRedirects={editDomainRedirects}
|
editDomainRedirects={editDomainRedirects}
|
||||||
defaultRedirects={defaultRedirects}
|
checkDomainHealth={checkDomainHealth}
|
||||||
|
defaultRedirects={resolvedDefaultRedirects}
|
||||||
|
selectedServer={selectedServer}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
7
src/domains/data/index.ts
Normal file
7
src/domains/data/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ShlinkDomain } from '../../api/types';
|
||||||
|
|
||||||
|
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||||
|
|
||||||
|
export interface Domain extends ShlinkDomain {
|
||||||
|
status: DomainStatus;
|
||||||
|
}
|
||||||
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { FC, useEffect, useRef, useState } from 'react';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faTimes as invalidIcon,
|
||||||
|
faCheck as checkIcon,
|
||||||
|
faCircleNotch as loadingStatusIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { MediaMatcher } from '../../utils/types';
|
||||||
|
import { DomainStatus } from '../data';
|
||||||
|
|
||||||
|
interface DomainStatusIconProps {
|
||||||
|
status: DomainStatus;
|
||||||
|
matchMedia?: MediaMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
||||||
|
const ref = useRef<HTMLSpanElement>();
|
||||||
|
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
||||||
|
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = () => setIsMobile(matchesMobile());
|
||||||
|
|
||||||
|
window.addEventListener('resize', listener);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === 'validating') {
|
||||||
|
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={(el: HTMLSpanElement) => {
|
||||||
|
ref.current = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'valid'
|
||||||
|
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
|
||||||
|
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
|
||||||
|
</span>
|
||||||
|
<UncontrolledTooltip
|
||||||
|
target={(() => ref.current) as any}
|
||||||
|
placement={isMobile ? 'top-start' : 'left'}
|
||||||
|
autohide={status === 'valid'}
|
||||||
|
>
|
||||||
|
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
|
||||||
|
<span>
|
||||||
|
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
|
||||||
|
<br />
|
||||||
|
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
|
||||||
|
find out what is missing.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { Domain, DomainStatus } from '../data';
|
||||||
|
import { hasServerData } from '../../servers/data';
|
||||||
|
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
@@ -12,24 +15,32 @@ export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
|||||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||||
|
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface DomainsList {
|
export interface DomainsList {
|
||||||
domains: ShlinkDomain[];
|
domains: Domain[];
|
||||||
filteredDomains: ShlinkDomain[];
|
filteredDomains: Domain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListDomainsAction extends Action<string> {
|
export interface ListDomainsAction extends Action<string> {
|
||||||
domains: ShlinkDomain[];
|
domains: Domain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterDomainsAction extends Action<string> {
|
interface FilterDomainsAction extends Action<string> {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidateDomain extends Action<string> {
|
||||||
|
domain: string;
|
||||||
|
status: DomainStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: DomainsList = {
|
const initialState: DomainsList = {
|
||||||
domains: [],
|
domains: [],
|
||||||
filteredDomains: [],
|
filteredDomains: [],
|
||||||
@@ -40,15 +51,20 @@ const initialState: DomainsList = {
|
|||||||
export type DomainsCombinedAction = ListDomainsAction
|
export type DomainsCombinedAction = ListDomainsAction
|
||||||
& ApiErrorAction
|
& ApiErrorAction
|
||||||
& FilterDomainsAction
|
& FilterDomainsAction
|
||||||
& EditDomainRedirectsAction;
|
& EditDomainRedirectsAction
|
||||||
|
& ValidateDomain;
|
||||||
|
|
||||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||||
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
|
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects };
|
||||||
|
|
||||||
|
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||||
|
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status };
|
||||||
|
|
||||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
|
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
|
||||||
|
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
|
||||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||||
...state,
|
...state,
|
||||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||||
@@ -58,6 +74,11 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
|
|||||||
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
}),
|
}),
|
||||||
|
[VALIDATE_DOMAIN]: (state, { domain, status }) => ({
|
||||||
|
...state,
|
||||||
|
domains: state.domains.map(replaceStatusOnDomain(domain, status)),
|
||||||
|
filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)),
|
||||||
|
}),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
@@ -68,12 +89,42 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
|||||||
const { listDomains } = buildShlinkApiClient(getState);
|
const { listDomains } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const domains = await listDomains();
|
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({
|
||||||
|
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||||
|
defaultRedirects,
|
||||||
|
}));
|
||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||||
|
|
||||||
|
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
|
) => {
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
|
||||||
|
if (!hasServerData(selectedServer)) {
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url, ...rest } = selectedServer;
|
||||||
|
const { health } = buildShlinkApiClient({
|
||||||
|
...rest,
|
||||||
|
url: replaceAuthorityFromUri(url, domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status } = await health();
|
||||||
|
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { filterDomains, listDomains } from '../reducers/domainsList';
|
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
||||||
import { DomainSelector } from '../DomainSelector';
|
import { DomainSelector } from '../DomainSelector';
|
||||||
import { ManageDomains } from '../ManageDomains';
|
import { ManageDomains } from '../ManageDomains';
|
||||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
@@ -12,14 +12,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
|
|
||||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
bottle.decorator('ManageDomains', connect(
|
bottle.decorator('ManageDomains', connect(
|
||||||
[ 'domainsList' ],
|
[ 'domainsList', 'selectedServer' ],
|
||||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { render } from 'react-dom';
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { homepage } from '../package.json';
|
import { homepage } from '../package.json';
|
||||||
import container from './container';
|
import { container } from './container';
|
||||||
import store from './container/store';
|
import { store } from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { combineReducers } from 'redux';
|
|||||||
import serversReducer from '../servers/reducers/servers';
|
import serversReducer from '../servers/reducers/servers';
|
||||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||||
@@ -24,7 +23,6 @@ export default combineReducers<ShlinkState>({
|
|||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: selectedServerReducer,
|
selectedServer: selectedServerReducer,
|
||||||
shortUrlsList: shortUrlsListReducer,
|
shortUrlsList: shortUrlsListReducer,
|
||||||
shortUrlsListParams: shortUrlsListParamsReducer,
|
|
||||||
shortUrlCreationResult: shortUrlCreationReducer,
|
shortUrlCreationResult: shortUrlCreationReducer,
|
||||||
shortUrlDeletion: shortUrlDeletionReducer,
|
shortUrlDeletion: shortUrlDeletionReducer,
|
||||||
shortUrlEdition: shortUrlEditionReducer,
|
shortUrlEdition: shortUrlEditionReducer,
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.create-server__label {
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-server__csv-select {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,76 @@
|
|||||||
import { FC } from 'react';
|
import { FC, useEffect, useState } 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, useToggle } 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 { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||||
|
|
||||||
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 [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
|
||||||
|
const [ serverData, setServerData ] = useState<ServerData | undefined>();
|
||||||
|
const save = () => {
|
||||||
|
if (!serverData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|
||||||
createServer({ ...serverData, id });
|
createServer({ ...serverData, id });
|
||||||
push(`/server/${id}`);
|
push(`/server/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const serverExists = Object.values(servers).some(
|
||||||
|
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
serverExists ? toggleConfirmModal() : save();
|
||||||
|
}, [ serverData ]);
|
||||||
|
|
||||||
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={setServerData}>
|
||||||
<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" />}
|
<DuplicatedServersModal
|
||||||
</div>
|
isOpen={isConfirmModalOpen}
|
||||||
)}
|
duplicatedServers={serverData ? [ serverData ] : []}
|
||||||
|
onDiscard={goBack}
|
||||||
|
onSave={save}
|
||||||
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
import { isServerWithId, ServerData } from './data';
|
import { isServerWithId, ServerData } from './data';
|
||||||
@@ -10,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 (
|
||||||
|
|||||||
86
src/servers/ManageServers.tsx
Normal file
86
src/servers/ManageServers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
src/servers/ManageServersRow.tsx
Normal file
38
src/servers/ManageServersRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
53
src/servers/ManageServersRowDropdown.tsx
Normal file
53
src/servers/ManageServersRowDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
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 { ITEMS_IN_OVERVIEW_PAGE, 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';
|
||||||
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
||||||
@@ -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,11 +40,11 @@ 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(() => {
|
||||||
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
|
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
||||||
listTags();
|
listTags();
|
||||||
loadVisitsOverview();
|
loadVisitsOverview();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { FC, Fragment } from 'react';
|
||||||
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
|
import { ServerData } from '../data';
|
||||||
|
|
||||||
|
interface DuplicatedServersModalProps {
|
||||||
|
duplicatedServers: ServerData[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||||
|
{ isOpen, duplicatedServers, onDiscard, onSave },
|
||||||
|
) => {
|
||||||
|
const hasMultipleServers = duplicatedServers.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal centered isOpen={isOpen}>
|
||||||
|
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||||
|
<ul>
|
||||||
|
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<li>URL: <b>{url}</b></li>
|
||||||
|
<li>API key: <b>{apiKey}</b></li>
|
||||||
|
</Fragment>
|
||||||
|
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)}
|
||||||
|
</ul>
|
||||||
|
<span>
|
||||||
|
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
||||||
|
</span>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicated' : 'Discard'}</Button>
|
||||||
|
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
src/servers/helpers/ImportServersBtn.scss
Normal file
5
src/servers/helpers/ImportServersBtn.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.import-servers-btn__csv-select {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
}
|
||||||
@@ -1,52 +1,91 @@
|
|||||||
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import ServersImporter from '../services/ServersImporter';
|
import { complement, pipe } from 'ramda';
|
||||||
import { ServerData } from '../data';
|
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
|
import { ServersImporter } from '../services/ServersImporter';
|
||||||
|
import { ServerData, ServersMap } from '../data';
|
||||||
|
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||||
|
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 {
|
||||||
createServers: (servers: ServerData[]) => void;
|
createServers: (servers: ServerData[]) => void;
|
||||||
|
servers: ServersMap;
|
||||||
fileRef: Ref<HTMLInputElement>;
|
fileRef: Ref<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
const serversFiltering = (servers: ServerData[]) =>
|
||||||
|
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||||
|
|
||||||
|
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||||
createServers,
|
createServers,
|
||||||
|
servers,
|
||||||
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 [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
|
||||||
|
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
|
||||||
|
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||||
|
const create = pipe(createServers, onImport);
|
||||||
|
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
|
||||||
|
const createNonDuplicatedServers = pipe(
|
||||||
|
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
|
||||||
|
hideModal,
|
||||||
|
);
|
||||||
|
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
importServersFromFile(target.files?.[0])
|
importServersFromFile(target.files?.[0])
|
||||||
.then(createServers)
|
.then(setServersToCreate)
|
||||||
.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;
|
||||||
})
|
})
|
||||||
.catch(onImportError);
|
.catch(onImportError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serversToCreate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingServers = Object.values(servers);
|
||||||
|
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
|
||||||
|
const hasDuplicatedServers = !!duplicatedServers.length;
|
||||||
|
|
||||||
|
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
|
||||||
|
hasDuplicatedServers && showModal();
|
||||||
|
}, [ serversToCreate ]);
|
||||||
|
|
||||||
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={onFile} />
|
||||||
|
|
||||||
|
<DuplicatedServersModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
duplicatedServers={duplicatedServers}
|
||||||
|
onDiscard={createNonDuplicatedServers}
|
||||||
|
onSave={createAllServers}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Message from '../../utils/Message';
|
|||||||
import ServersListGroup from '../ServersListGroup';
|
import ServersListGroup from '../ServersListGroup';
|
||||||
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||||
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
import './ServerError.scss';
|
import './ServerError.scss';
|
||||||
|
|
||||||
interface ServerErrorProps {
|
interface ServerErrorProps {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
@import '../../utils/base';
|
||||||
|
|
||||||
.server-form .form-group:last-child {
|
.server-form .form-group:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-form__label {
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface ServerFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||||
<FormGroupContainer {...props} labelClassName="create-server__label" />;
|
<FormGroupContainer {...props} labelClassName="server-form__label" />;
|
||||||
|
|
||||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||||
const [ name, setName ] = useState('');
|
const [ name, setName ] = useState('');
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FC, useEffect } from 'react';
|
|||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import Message from '../../utils/Message';
|
import Message from '../../utils/Message';
|
||||||
import { isNotFoundServer, SelectedServer } from '../data';
|
import { isNotFoundServer, SelectedServer } from '../data';
|
||||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
|
|
||||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||||
selectServer: (serverId: string) => void;
|
selectServer: (serverId: string) => void;
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { identity, memoizeWith, pipe } from 'ramda';
|
import { identity, memoizeWith, pipe } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||||
import { SelectedServer } from '../data';
|
import { SelectedServer } from '../data';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
@@ -53,7 +52,6 @@ export const selectServer = (
|
|||||||
getState: GetState,
|
getState: GetState,
|
||||||
) => {
|
) => {
|
||||||
dispatch(resetSelectedServer());
|
dispatch(resetSelectedServer());
|
||||||
dispatch(resetShortUrlParams());
|
|
||||||
|
|
||||||
const { servers } = getState();
|
const { servers } = getState();
|
||||||
const selectedServer = servers[serverId];
|
const selectedServer = servers[serverId];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const validateServer = (server: any): server is ServerData =>
|
|||||||
const validateServers = (servers: any): servers is ServerData[] =>
|
const validateServers = (servers: any): servers is ServerData[] =>
|
||||||
Array.isArray(servers) && servers.every(validateServer);
|
Array.isArray(servers) && servers.every(validateServer);
|
||||||
|
|
||||||
export default class ServersImporter {
|
export class ServersImporter {
|
||||||
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||||
|
|
||||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||||
|
|||||||
@@ -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 ServersImporter from './ServersImporter';
|
import { ManageServers } from '../ManageServers';
|
||||||
|
import { ManageServersRow } from '../ManageServersRow';
|
||||||
|
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
||||||
|
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);
|
||||||
@@ -36,7 +54,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
||||||
|
|
||||||
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||||
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
|
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
||||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ interface RealTimeUpdatesProps {
|
|||||||
|
|
||||||
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
||||||
|
|
||||||
const RealTimeUpdates = (
|
const RealTimeUpdatesSettings = (
|
||||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||||
) => (
|
) => (
|
||||||
<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>
|
||||||
@@ -50,4 +50,4 @@ const RealTimeUpdates = (
|
|||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default RealTimeUpdates;
|
export default RealTimeUpdatesSettings;
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { FC, ReactNode } from 'react';
|
import { FC, ReactNode } from 'react';
|
||||||
import { Row } from 'reactstrap';
|
import { Row } from 'reactstrap';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
|
|
||||||
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
|
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
|
||||||
<>
|
<>
|
||||||
{items.map((child, index) => (
|
{items.map((child, index) => (
|
||||||
<Row key={index}>
|
<Row key={index}>
|
||||||
{child.map((subChild, subIndex) => (
|
{child.map((subChild, subIndex) => (
|
||||||
<div key={subIndex} className="col-lg-6 mb-3">
|
<div key={subIndex} className={`col-lg-${12 / child.length} mb-3`}>
|
||||||
{subChild}
|
{subChild}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -16,12 +16,20 @@ const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => (
|
const Settings = (
|
||||||
|
RealTimeUpdates: FC,
|
||||||
|
ShortUrlCreation: FC,
|
||||||
|
ShortUrlsList: FC,
|
||||||
|
UserInterface: FC,
|
||||||
|
Visits: FC,
|
||||||
|
Tags: FC,
|
||||||
|
) => () => (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<SettingsSections
|
<SettingsSections
|
||||||
items={[
|
items={[
|
||||||
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
||||||
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
[ <ShortUrlCreation />, <ShortUrlsList /> ], // eslint-disable-line react/jsx-key
|
||||||
|
[ <Tags />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|||||||
@@ -3,40 +3,52 @@ import { DropdownItem, FormGroup } from 'reactstrap';
|
|||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings';
|
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
|
||||||
|
|
||||||
interface ShortUrlCreationProps {
|
interface ShortUrlCreationProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||||
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 ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||||
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||||
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
||||||
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
||||||
);
|
);
|
||||||
|
|
||||||
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)}>
|
||||||
26
src/settings/ShortUrlsListSettings.tsx
Normal file
26
src/settings/ShortUrlsListSettings.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
|
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
|
||||||
|
|
||||||
|
interface ShortUrlsListProps {
|
||||||
|
settings: Settings;
|
||||||
|
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShortUrlsListSettings: FC<ShortUrlsListProps> = (
|
||||||
|
{ settings: { shortUrlsList }, setShortUrlsListSettings },
|
||||||
|
) => (
|
||||||
|
<SimpleCard title="Short URLs list" className="h-100">
|
||||||
|
<FormGroup className="mb-0">
|
||||||
|
<label>Default ordering for short URLs list:</label>
|
||||||
|
<OrderingDropdown
|
||||||
|
items={SHORT_URLS_ORDERABLE_FIELDS}
|
||||||
|
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
|
||||||
|
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
||||||
35
src/settings/TagsSettings.tsx
Normal file
35
src/settings/TagsSettings.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||||
|
import { capitalize } from '../utils/utils';
|
||||||
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
|
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
|
||||||
|
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
|
||||||
|
|
||||||
|
interface TagsProps {
|
||||||
|
settings: Settings;
|
||||||
|
setTagsSettings: (settings: TagsSettingsOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
|
||||||
|
<SimpleCard title="Tags" className="h-100">
|
||||||
|
<FormGroup>
|
||||||
|
<label>Default display mode when managing tags:</label>
|
||||||
|
<TagsModeDropdown
|
||||||
|
mode={tags?.defaultMode ?? 'cards'}
|
||||||
|
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||||
|
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
|
||||||
|
/>
|
||||||
|
<small className="form-text text-muted">Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</small>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="mb-0">
|
||||||
|
<label>Default ordering for tags list:</label>
|
||||||
|
<OrderingDropdown
|
||||||
|
items={TAGS_ORDERABLE_FIELDS}
|
||||||
|
order={tags?.defaultOrdering ?? {}}
|
||||||
|
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
||||||
@@ -5,17 +5,15 @@ import { FormGroup } from 'reactstrap';
|
|||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
|
||||||
import { capitalize } from '../utils/utils';
|
|
||||||
import { Settings, UiSettings } from './reducers/settings';
|
import { Settings, UiSettings } from './reducers/settings';
|
||||||
import './UserInterface.scss';
|
import './UserInterfaceSettings.scss';
|
||||||
|
|
||||||
interface UserInterfaceProps {
|
interface UserInterfaceProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setUiSettings: (settings: UiSettings) => void;
|
setUiSettings: (settings: UiSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||||
<SimpleCard title="User interface" className="h-100">
|
<SimpleCard title="User interface" className="h-100">
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||||
@@ -31,14 +29,5 @@ export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiS
|
|||||||
Use dark theme.
|
Use dark theme.
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="mb-0">
|
|
||||||
<label>Default display mode when managing tags:</label>
|
|
||||||
<TagsModeDropdown
|
|
||||||
mode={ui?.tagsMode ?? 'cards'}
|
|
||||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
|
||||||
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
|
|
||||||
/>
|
|
||||||
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
|
|
||||||
</FormGroup>
|
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
@@ -2,18 +2,19 @@ import { FormGroup } from 'reactstrap';
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
||||||
import { Settings, VisitsSettings } from './reducers/settings';
|
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
|
||||||
|
|
||||||
interface VisitsProps {
|
interface VisitsProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setVisitsSettings: (settings: VisitsSettings) => void;
|
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||||
<SimpleCard title="Visits" className="h-100">
|
<SimpleCard title="Visits" className="h-100">
|
||||||
<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 })}
|
||||||
/>
|
/>
|
||||||
21
src/settings/helpers/index.ts
Normal file
21
src/settings/helpers/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ShlinkState } from '../../container/types';
|
||||||
|
|
||||||
|
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
|
||||||
|
if (!state.settings) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "last180Days" interval had a typo, with a lowercase d
|
||||||
|
if ((state.settings.visits?.defaultInterval as any) === 'last180days') {
|
||||||
|
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "tags display mode" option has been moved from "ui" to "tags"
|
||||||
|
state.settings.tags = {
|
||||||
|
...state.settings.tags,
|
||||||
|
defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode,
|
||||||
|
};
|
||||||
|
state.settings.ui && delete (state.settings.ui as any).tagsMode;
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
@@ -4,9 +4,16 @@ import { buildReducer } from '../../utils/helpers/redux';
|
|||||||
import { RecursivePartial } from '../../utils/utils';
|
import { RecursivePartial } from '../../utils/utils';
|
||||||
import { Theme } from '../../utils/theme';
|
import { Theme } from '../../utils/theme';
|
||||||
import { DateInterval } from '../../utils/dates/types';
|
import { DateInterval } from '../../utils/dates/types';
|
||||||
|
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
||||||
|
import { ShortUrlsOrder } from '../../short-urls/data';
|
||||||
|
|
||||||
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
||||||
|
|
||||||
|
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||||
|
field: 'dateCreated',
|
||||||
|
dir: 'DESC',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as
|
* Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as
|
||||||
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
||||||
@@ -22,24 +29,35 @@ 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';
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
tagsMode?: TagsMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsSettings {
|
export interface VisitsSettings {
|
||||||
defaultInterval: DateInterval;
|
defaultInterval: DateInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagsSettings {
|
||||||
|
defaultOrdering?: TagsOrder;
|
||||||
|
defaultMode?: TagsMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlsListSettings {
|
||||||
|
defaultOrdering?: ShortUrlsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
realTimeUpdates: RealTimeUpdatesSettings;
|
realTimeUpdates: RealTimeUpdatesSettings;
|
||||||
shortUrlCreation?: ShortUrlCreationSettings;
|
shortUrlCreation?: ShortUrlCreationSettings;
|
||||||
|
shortUrlsList?: ShortUrlsListSettings;
|
||||||
ui?: UiSettings;
|
ui?: UiSettings;
|
||||||
visits?: VisitsSettings;
|
visits?: VisitsSettings;
|
||||||
|
tags?: TagsSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: Settings = {
|
const initialState: Settings = {
|
||||||
@@ -55,6 +73,9 @@ const initialState: Settings = {
|
|||||||
visits: {
|
visits: {
|
||||||
defaultInterval: 'last30Days',
|
defaultInterval: 'last30Days',
|
||||||
},
|
},
|
||||||
|
shortUrlsList: {
|
||||||
|
defaultOrdering: DEFAULT_SHORT_URLS_ORDERING,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsAction = Action & Settings;
|
type SettingsAction = Action & Settings;
|
||||||
@@ -80,6 +101,11 @@ export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings):
|
|||||||
shortUrlCreation: settings,
|
shortUrlCreation: settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setShortUrlsListSettings = (settings: ShortUrlsListSettings): PartialSettingsAction => ({
|
||||||
|
type: SET_SETTINGS,
|
||||||
|
shortUrlsList: settings,
|
||||||
|
});
|
||||||
|
|
||||||
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
|
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
|
||||||
type: SET_SETTINGS,
|
type: SET_SETTINGS,
|
||||||
ui: settings,
|
ui: settings,
|
||||||
@@ -89,3 +115,8 @@ export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsActi
|
|||||||
type: SET_SETTINGS,
|
type: SET_SETTINGS,
|
||||||
visits: settings,
|
visits: settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({
|
||||||
|
type: SET_SETTINGS,
|
||||||
|
tags: settings,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,46 +1,67 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import RealTimeUpdates from '../RealTimeUpdates';
|
import RealTimeUpdatesSettings from '../RealTimeUpdatesSettings';
|
||||||
import Settings from '../Settings';
|
import Settings from '../Settings';
|
||||||
import {
|
import {
|
||||||
setRealTimeUpdatesInterval,
|
setRealTimeUpdatesInterval,
|
||||||
setShortUrlCreationSettings,
|
setShortUrlCreationSettings,
|
||||||
|
setShortUrlsListSettings,
|
||||||
|
setTagsSettings,
|
||||||
setUiSettings,
|
setUiSettings,
|
||||||
setVisitsSettings,
|
setVisitsSettings,
|
||||||
toggleRealTimeUpdates,
|
toggleRealTimeUpdates,
|
||||||
} from '../reducers/settings';
|
} from '../reducers/settings';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
import { ShortUrlCreation } from '../ShortUrlCreation';
|
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
|
||||||
import { UserInterface } from '../UserInterface';
|
import { UserInterfaceSettings } from '../UserInterfaceSettings';
|
||||||
import { Visits } from '../Visits';
|
import { VisitsSettings } from '../VisitsSettings';
|
||||||
|
import { TagsSettings } from '../TagsSettings';
|
||||||
|
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits');
|
bottle.serviceFactory(
|
||||||
|
'Settings',
|
||||||
|
Settings,
|
||||||
|
'RealTimeUpdatesSettings',
|
||||||
|
'ShortUrlCreationSettings',
|
||||||
|
'ShortUrlsListSettings',
|
||||||
|
'UserInterfaceSettings',
|
||||||
|
'VisitsSettings',
|
||||||
|
'TagsSettings',
|
||||||
|
);
|
||||||
bottle.decorator('Settings', withoutSelectedServer);
|
bottle.decorator('Settings', withoutSelectedServer);
|
||||||
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
|
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
|
||||||
bottle.decorator(
|
bottle.decorator(
|
||||||
'RealTimeUpdates',
|
'RealTimeUpdatesSettings',
|
||||||
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
|
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation);
|
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
|
||||||
bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
|
bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('UserInterface', () => UserInterface);
|
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
|
||||||
bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ]));
|
bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('Visits', () => Visits);
|
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
|
||||||
bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ]));
|
bottle.decorator('VisitsSettings', connect([ 'settings' ], [ 'setVisitsSettings' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('TagsSettings', () => TagsSettings);
|
||||||
|
bottle.decorator('TagsSettings', connect([ 'settings' ], [ 'setTagsSettings' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
|
||||||
|
bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ]));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
||||||
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
|
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
|
||||||
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
|
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
|
||||||
|
bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings);
|
||||||
bottle.serviceFactory('setUiSettings', () => setUiSettings);
|
bottle.serviceFactory('setUiSettings', () => setUiSettings);
|
||||||
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
|
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
|
||||||
|
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
@@ -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>) => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
.search-bar__tags-icon {
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { isEmpty, pipe } from 'ramda';
|
|
||||||
import { parseISO } from 'date-fns';
|
|
||||||
import SearchField from '../utils/SearchField';
|
|
||||||
import Tag from '../tags/helpers/Tag';
|
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
|
||||||
import { DateRange } from '../utils/dates/types';
|
|
||||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
|
||||||
import './SearchBar.scss';
|
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
|
||||||
|
|
||||||
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
|
||||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
|
||||||
const setDates = pipe(
|
|
||||||
({ startDate, endDate }: DateRange) => ({
|
|
||||||
startDate: formatIsoDate(startDate) ?? undefined,
|
|
||||||
endDate: formatIsoDate(endDate) ?? undefined,
|
|
||||||
}),
|
|
||||||
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="search-bar-container">
|
|
||||||
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
|
|
||||||
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
|
||||||
<DateRangeSelector
|
|
||||||
defaultText="All short URLs"
|
|
||||||
initialDateRange={{
|
|
||||||
startDate: dateOrNull(shortUrlsListParams.startDate),
|
|
||||||
endDate: dateOrNull(shortUrlsListParams.endDate),
|
|
||||||
}}
|
|
||||||
onDatesChange={setDates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
|
||||||
|
|
||||||
{selectedTags.map((tag) => (
|
|
||||||
<Tag
|
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
key={tag}
|
|
||||||
text={tag}
|
|
||||||
clearable
|
|
||||||
onClose={() => listShortUrls(
|
|
||||||
{
|
|
||||||
...shortUrlsListParams,
|
|
||||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchBar;
|
|
||||||
@@ -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>,
|
||||||
@@ -40,6 +41,7 @@ export const ShortUrlForm = (
|
|||||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
||||||
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
const isBasicMode = mode === 'create-basic';
|
||||||
const hadTitleOriginally = hasValue(initialState.title);
|
const hadTitleOriginally = hasValue(initialState.title);
|
||||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||||
const reset = () => setShortUrlData(initialState);
|
const reset = () => setShortUrlData(initialState);
|
||||||
@@ -65,8 +67,14 @@ export const ShortUrlForm = (
|
|||||||
setShortUrlData(initialState);
|
setShortUrlData(initialState);
|
||||||
}, [ initialState ]);
|
}, [ initialState ]);
|
||||||
|
|
||||||
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
|
const renderOptionalInput = (
|
||||||
<FormGroup>
|
id: NonDateFields,
|
||||||
|
placeholder: string,
|
||||||
|
type: InputType = 'text',
|
||||||
|
props = {},
|
||||||
|
fromGroupProps = {},
|
||||||
|
) => (
|
||||||
|
<FormGroup {...fromGroupProps}>
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
@@ -100,25 +108,27 @@ export const ShortUrlForm = (
|
|||||||
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
|
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
<Row>
|
||||||
<FormGroup>
|
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
||||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
<FormGroup className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
|
||||||
</FormGroup>
|
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||||
|
</FormGroup>
|
||||||
|
</Row>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
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}>
|
||||||
{mode === 'create-basic' && basicComponents}
|
{isBasicMode && basicComponents}
|
||||||
{mode !== 'create-basic' && (
|
{!isBasicMode && (
|
||||||
<>
|
<>
|
||||||
<SimpleCard title="Basic options" className="mb-3">
|
<SimpleCard title="Basic options" className="mb-3">
|
||||||
{basicComponents}
|
{basicComponents}
|
||||||
@@ -165,37 +175,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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
3
src/short-urls/ShortUrlsFilteringBar.scss
Normal file
3
src/short-urls/ShortUrlsFilteringBar.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.short-urls-filtering-bar__tags-icon {
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
70
src/short-urls/ShortUrlsFilteringBar.tsx
Normal file
70
src/short-urls/ShortUrlsFilteringBar.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { isEmpty, pipe } from 'ramda';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
|
import SearchField from '../utils/SearchField';
|
||||||
|
import Tag from '../tags/helpers/Tag';
|
||||||
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
|
import { DateRange } from '../utils/dates/types';
|
||||||
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
|
import './ShortUrlsFilteringBar.scss';
|
||||||
|
|
||||||
|
export type ShortUrlsFilteringProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
||||||
|
|
||||||
|
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||||
|
|
||||||
|
const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortUrlsFilteringProps) => {
|
||||||
|
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
||||||
|
const selectedTags = tags?.split(',') ?? [];
|
||||||
|
const setDates = pipe(
|
||||||
|
({ startDate, endDate }: DateRange) => ({
|
||||||
|
startDate: formatIsoDate(startDate) ?? undefined,
|
||||||
|
endDate: formatIsoDate(endDate) ?? undefined,
|
||||||
|
}),
|
||||||
|
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 (
|
||||||
|
<div className="short-urls-filtering-bar-container">
|
||||||
|
<SearchField initialValue={search} onChange={setSearch} />
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||||
|
<DateRangeSelector
|
||||||
|
defaultText="All short URLs"
|
||||||
|
initialDateRange={{
|
||||||
|
startDate: dateOrNull(startDate),
|
||||||
|
endDate: dateOrNull(endDate),
|
||||||
|
}}
|
||||||
|
onDatesChange={setDates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<h4 className="short-urls-filtering-bar__selected-tag mt-3">
|
||||||
|
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon" />
|
||||||
|
|
||||||
|
{selectedTags.map((tag) =>
|
||||||
|
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortUrlsFilteringBar;
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.short-urls-list__header-icon {
|
|
||||||
margin-left: .4rem;
|
|
||||||
}
|
|
||||||
@@ -1,101 +1,84 @@
|
|||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
import { pipe } 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 { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
import { determineOrderDir, 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 { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
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';
|
||||||
|
import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
||||||
|
|
||||||
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: ShlinkShortUrlsListParams) => void;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
settings: Settings;
|
||||||
resetShortUrlParams: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteringBar: FC) => boundToMercureHub(({
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
|
||||||
shortUrlsListParams,
|
|
||||||
match,
|
match,
|
||||||
location,
|
location,
|
||||||
|
history,
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
|
settings,
|
||||||
}: ShortUrlsListProps) => {
|
}: ShortUrlsListProps) => {
|
||||||
const { orderBy } = shortUrlsListParams;
|
const serverId = getServerId(selectedServer);
|
||||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
const [{ tags, search, startDate, endDate, orderBy }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
||||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
const [ actualOrderBy, setActualOrderBy ] = useState(
|
||||||
orderDir: orderBy && head(values(orderBy)),
|
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
||||||
});
|
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
||||||
|
);
|
||||||
|
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
||||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||||
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
toFirstPage({ orderBy: { field, dir } });
|
||||||
setOrder({ orderField, orderDir });
|
setActualOrderBy({ field, dir });
|
||||||
refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined });
|
|
||||||
};
|
|
||||||
const orderByColumn = (field: OrderableFields) => () =>
|
|
||||||
handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir));
|
|
||||||
const renderOrderIcon = (field: OrderableFields) => {
|
|
||||||
if (order.orderField !== field) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!order.orderDir) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
|
||||||
className="short-urls-list__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
const orderByColumn = (field: ShortUrlsOrderableFields) => () =>
|
||||||
|
handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir));
|
||||||
|
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
||||||
|
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
||||||
|
const addTag = pipe(
|
||||||
|
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
||||||
|
(tags) => toFirstPage({ tags }),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
listShortUrls({
|
||||||
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags;
|
page: match.params.page,
|
||||||
|
searchTerm: search,
|
||||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
tags: selectedTags,
|
||||||
|
startDate,
|
||||||
return resetShortUrlParams;
|
endDate,
|
||||||
}, []);
|
orderBy: actualOrderBy,
|
||||||
|
});
|
||||||
|
}, [ match.params.page, search, selectedTags, startDate, endDate, actualOrderBy ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-3"><ShortUrlsFilteringBar /></div>
|
||||||
<div className="d-block d-lg-none mb-3">
|
<div className="d-block d-lg-none mb-3">
|
||||||
<SortingDropdown
|
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={actualOrderBy} 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { SelectedServer } from '../servers/data';
|
|||||||
import { supportsShortUrlTitle } from '../utils/helpers/features';
|
import { supportsShortUrlTitle } from '../utils/helpers/features';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
||||||
import { OrderableFields } from './reducers/shortUrlsListParams';
|
import { ShortUrlsOrderableFields } from './data';
|
||||||
import './ShortUrlsTable.scss';
|
import './ShortUrlsTable.scss';
|
||||||
|
|
||||||
export interface ShortUrlsTableProps {
|
export interface ShortUrlsTableProps {
|
||||||
orderByColumn?: (column: OrderableFields) => () => void;
|
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||||
renderOrderIcon?: (column: OrderableFields) => ReactNode;
|
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
@@ -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>
|
||||||
/
|
/
|
||||||
<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"> </th>
|
<th className="short-urls-table__header-cell"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Nullable, OptionalString } from '../../utils/utils';
|
import { Nullable, OptionalString } from '../../utils/utils';
|
||||||
|
import { Order } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
export interface EditShortUrlData {
|
export interface EditShortUrlData {
|
||||||
longUrl?: string;
|
longUrl?: string;
|
||||||
@@ -9,6 +10,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 +32,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 {
|
||||||
@@ -48,3 +51,15 @@ export interface ShortUrlIdentifier {
|
|||||||
shortCode: string;
|
shortCode: string;
|
||||||
domain: OptionalString;
|
domain: OptionalString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
||||||
|
dateCreated: 'Created at',
|
||||||
|
shortCode: 'Short URL',
|
||||||
|
longUrl: 'Long URL',
|
||||||
|
title: 'Title',
|
||||||
|
visits: 'Visits',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
|
||||||
|
|
||||||
|
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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} />
|
||||||
|
|||||||
54
src/short-urls/helpers/hooks.ts
Normal file
54
src/short-urls/helpers/hooks.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { isEmpty, pipe } from 'ramda';
|
||||||
|
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||||
|
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
|
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
||||||
|
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||||
|
|
||||||
|
export interface ShortUrlListRouteParams {
|
||||||
|
page: string;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortUrlsQueryCommon {
|
||||||
|
tags?: string;
|
||||||
|
search?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
||||||
|
orderBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||||
|
orderBy?: ShortUrlsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useShortUrlsQuery = (
|
||||||
|
{ history, location, match }: ServerIdRouteProps,
|
||||||
|
): [ShortUrlsFiltering, ToFirstPage] => {
|
||||||
|
const query = useMemo(
|
||||||
|
pipe(
|
||||||
|
() => parseQuery<ShortUrlsQuery>(location.search),
|
||||||
|
({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : {
|
||||||
|
...rest,
|
||||||
|
orderBy: stringToOrder<ShortUrlsOrderableFields>(orderBy),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
[ location.search ],
|
||||||
|
);
|
||||||
|
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||||
|
const { orderBy, ...mergedQuery } = { ...query, ...extra };
|
||||||
|
const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) };
|
||||||
|
const evolvedQuery = stringifyQuery(normalizedQuery);
|
||||||
|
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||||
|
|
||||||
|
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [ query, toFirstPageWithExtra ];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
|
import { assoc, assocPath, last, pipe, reject } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
||||||
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 { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||||
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
||||||
|
|
||||||
@@ -17,6 +16,8 @@ export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR
|
|||||||
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export const ITEMS_IN_OVERVIEW_PAGE = 5;
|
||||||
|
|
||||||
export interface ShortUrlsList {
|
export interface ShortUrlsList {
|
||||||
shortUrls?: ShlinkShortUrlsResponse;
|
shortUrls?: ShlinkShortUrlsResponse;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -25,7 +26,6 @@ export interface ShortUrlsList {
|
|||||||
|
|
||||||
export interface ListShortUrlsAction extends Action<string> {
|
export interface ListShortUrlsAction extends Action<string> {
|
||||||
shortUrls: ShlinkShortUrlsResponse;
|
shortUrls: ShlinkShortUrlsResponse;
|
||||||
params: ShortUrlsListParams;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListShortUrlsCombinedAction = (
|
export type ListShortUrlsCombinedAction = (
|
||||||
@@ -77,10 +77,11 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
|||||||
),
|
),
|
||||||
[CREATE_SHORT_URL]: pipe(
|
[CREATE_SHORT_URL]: pipe(
|
||||||
// The only place where the list and the creation form coexist is the overview page.
|
// The only place where the list and the creation form coexist is the overview page.
|
||||||
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one.
|
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
|
||||||
|
// We can also remove the items above the amount that is displayed there.
|
||||||
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
|
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
|
||||||
[ 'shortUrls', 'data' ],
|
[ 'shortUrls', 'data' ],
|
||||||
[ result, ...init(state.shortUrls.data) ],
|
[ result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1) ],
|
||||||
state,
|
state,
|
||||||
),
|
),
|
||||||
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
|
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
|
||||||
@@ -101,7 +102,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);
|
||||||
@@ -109,8 +110,8 @@ export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
|||||||
try {
|
try {
|
||||||
const shortUrls = await listShortUrls(params);
|
const shortUrls = await listShortUrls(params);
|
||||||
|
|
||||||
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls, params });
|
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
|
dispatch({ type: LIST_SHORT_URLS_ERROR });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { OrderDir } from '../../utils/utils';
|
|
||||||
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
|
||||||
|
|
||||||
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
|
||||||
|
|
||||||
export const SORTABLE_FIELDS = {
|
|
||||||
dateCreated: 'Created at',
|
|
||||||
shortCode: 'Short URL',
|
|
||||||
longUrl: 'Long URL',
|
|
||||||
title: 'Title',
|
|
||||||
visits: 'Visits',
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
|
||||||
|
|
||||||
export interface ShortUrlsListParams {
|
|
||||||
page?: string;
|
|
||||||
itemsPerPage?: number;
|
|
||||||
tags?: string[];
|
|
||||||
searchTerm?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
orderBy?: Partial<Record<OrderableFields, OrderDir>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlsListParams = {
|
|
||||||
page: '1',
|
|
||||||
orderBy: { dateCreated: 'DESC' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default buildReducer<ShortUrlsListParams, ListShortUrlsAction>({
|
|
||||||
[LIST_SHORT_URLS]: (state, { params }) => ({ ...state, ...params }),
|
|
||||||
[RESET_SHORT_URL_PARAMS]: () => initialState,
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const resetShortUrlParams = buildActionCreator(RESET_SHORT_URL_PARAMS);
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import ShortUrls from '../ShortUrls';
|
import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar';
|
||||||
import SearchBar from '../SearchBar';
|
|
||||||
import ShortUrlsList from '../ShortUrlsList';
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
||||||
@@ -10,7 +9,6 @@ import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
|
|||||||
import { listShortUrls } from '../reducers/shortUrlsList';
|
import { listShortUrls } from '../reducers/shortUrlsList';
|
||||||
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
||||||
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
||||||
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
|
||||||
import { editShortUrl } from '../reducers/shortUrlEdition';
|
import { editShortUrl } from '../reducers/shortUrlEdition';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { ShortUrlsTable } from '../ShortUrlsTable';
|
import { ShortUrlsTable } from '../ShortUrlsTable';
|
||||||
@@ -19,15 +17,12 @@ 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', 'ShortUrlsFilteringBar');
|
||||||
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
|
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
[ 'selectedServer', 'mercureInfo', 'shortUrlsList', 'settings' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'listShortUrls', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
|
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
|
||||||
@@ -55,12 +50,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator');
|
||||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
bottle.decorator('ShortUrlsFilteringBar', withRouter);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
|
|
||||||
|
|
||||||
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
|
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,18 @@ 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 { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
import {
|
||||||
|
TagsOrderableFields,
|
||||||
|
TAGS_ORDERABLE_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 +30,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.tags?.defaultMode ?? 'cards');
|
||||||
|
const [ order, setOrder ] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
|
||||||
|
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 +52,53 @@ 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: TagsOrderableFields) => () => {
|
||||||
|
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">
|
||||||
|
<OrderingDropdown
|
||||||
|
items={TAGS_ORDERABLE_FIELDS}
|
||||||
|
order={order}
|
||||||
|
onChange={(field, dir) => setOrder({ field, dir })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</>
|
</>
|
||||||
|
|||||||
10
src/tags/TagsTable.scss
Normal file
10
src/tags/TagsTable.scss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 { TagsOrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||||
import { TagsTableRowProps } from './TagsTableRow';
|
import { TagsTableRowProps } from './TagsTableRow';
|
||||||
|
import './TagsTable.scss';
|
||||||
|
|
||||||
|
export interface TagsTableProps extends TagsListChildrenProps {
|
||||||
|
orderByColumn: (field: TagsOrderableFields) => () => 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user