mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-28 04:36:45 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
785806b7a1 | ||
|
|
15b7fd5c93 | ||
|
|
9b32bd2817 | ||
|
|
8b5b035568 | ||
|
|
f7cc90bb77 | ||
|
|
7b0cda7191 | ||
|
|
9791486341 | ||
|
|
40ef51a348 | ||
|
|
a90287ed02 | ||
|
|
12f6a132bd | ||
|
|
1da7119c5c | ||
|
|
01f6f11ee2 | ||
|
|
57d4db5daa | ||
|
|
c7559e78a2 | ||
|
|
2f76c5381f | ||
|
|
304a7431ad | ||
|
|
691dabcfbc | ||
|
|
2dd35dcd44 | ||
|
|
44930b8c5f | ||
|
|
310913b222 | ||
|
|
b877aa8e5b | ||
|
|
27e3d65143 | ||
|
|
b462169e1e | ||
|
|
dc2f30c73b | ||
|
|
8df1ba4671 | ||
|
|
56a3dbd07f | ||
|
|
856ee6d65c | ||
|
|
9518a5e442 | ||
|
|
3a8c7a7bf4 | ||
|
|
7fb0658349 | ||
|
|
6d79851d18 | ||
|
|
f89e4244ea | ||
|
|
3c23016028 | ||
|
|
27c4bd792b | ||
|
|
1b158b3df4 | ||
|
|
19f0dc2920 | ||
|
|
a15917b1ae | ||
|
|
7e5397dd38 | ||
|
|
382d7b1c9f | ||
|
|
58ee123cef | ||
|
|
039a56f410 | ||
|
|
6780aa623b | ||
|
|
7752140c9d | ||
|
|
f54460e8f8 | ||
|
|
036c8aafcb | ||
|
|
d55160e8f6 | ||
|
|
0572bc2854 | ||
|
|
aceb2350cf | ||
|
|
923575b38b | ||
|
|
f41a8473f8 | ||
|
|
b94cdb2680 | ||
|
|
0cdae72ebd | ||
|
|
75931edc33 | ||
|
|
d1fcd10c04 | ||
|
|
06f4cff97e | ||
|
|
0804322a9f | ||
|
|
53ba14e6f6 | ||
|
|
ead5f2033b | ||
|
|
74ac122787 | ||
|
|
13785c7beb | ||
|
|
9887cae4fd | ||
|
|
410d372755 | ||
|
|
e7a969a78d | ||
|
|
b1d6f58619 | ||
|
|
f49b74229c | ||
|
|
d88f822125 | ||
|
|
dce1cefd49 | ||
|
|
8e71b2e2b1 | ||
|
|
69cb3bd619 | ||
|
|
bf29158a8a | ||
|
|
a28a4846bc | ||
|
|
5eee86003d | ||
|
|
37a3a2022b | ||
|
|
c6be8bd96f | ||
|
|
5166340779 | ||
|
|
520e52595f | ||
|
|
461c0e0bc9 | ||
|
|
0ecb771b23 | ||
|
|
c89e2b5d25 | ||
|
|
aa8f2a0cbc | ||
|
|
eb90aa2274 | ||
|
|
2b5420a429 | ||
|
|
3484e74559 | ||
|
|
edd536cc1e | ||
|
|
322396a366 | ||
|
|
9f02bc6496 | ||
|
|
590393dcfd | ||
|
|
8029823271 | ||
|
|
4417a17d5c | ||
|
|
b8a7dccf92 | ||
|
|
cbe5f98aa3 | ||
|
|
6c2f5b99ac |
@@ -14,5 +14,8 @@
|
|||||||
"process": true,
|
"process": true,
|
||||||
"setImmediate": true
|
"setImmediate": true
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["src/service*.ts"]
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"complexity": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
.github/workflows/deploy-preview.yml
vendored
18
.github/workflows/deploy-preview.yml
vendored
@@ -17,25 +17,13 @@ jobs:
|
|||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.15
|
node-version: 14.15
|
||||||
- name: Generate slug
|
|
||||||
id: generate_slug
|
|
||||||
run: echo "##[set-output name=slug;]$(echo ${GITHUB_HEAD_REF#refs/heads/} | sed -r 's/[~\^]+//g' | sed -r 's/[^a-zA-Z0-9]+/-/g' | sed -r 's/^-+\|-+$//g' | tr A-Z a-z)"
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
npm ci && \
|
npm ci && \
|
||||||
node ./scripts/set-homepage.js /shlink-web-client/${{ steps.generate_slug.outputs.slug }} && \
|
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||||
rm src/service-worker.ts && \
|
rm src/service-worker.ts && \
|
||||||
npm run build
|
npm run build
|
||||||
- name: Deploy
|
- name: Deploy preview
|
||||||
uses: JamesIves/github-pages-deploy-action@4.1.1
|
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||||
with:
|
with:
|
||||||
branch: preview-env
|
|
||||||
folder: build
|
folder: build
|
||||||
target-folder: ${{ steps.generate_slug.outputs.slug }}
|
|
||||||
- name: Publish env
|
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
|
||||||
with:
|
|
||||||
header: Preview environment
|
|
||||||
message: |
|
|
||||||
## Preview environment
|
|
||||||
https://shlinkio.github.io/shlink-web-client/${{ steps.generate_slug.outputs.slug }}/
|
|
||||||
|
|||||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -4,6 +4,39 @@ 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.3.0] - 2021-09-25
|
||||||
|
### Added
|
||||||
|
* [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
|
||||||
|
* [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title.
|
||||||
|
* [#462](https://github.com/shlinkio/shlink-web-client/issues/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
|
||||||
|
* [#463](https://github.com/shlinkio/shlink-web-client/issues/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
|
||||||
|
|
||||||
|
* `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
|
||||||
|
* `includes`: Suggests tags that contain the input.
|
||||||
|
|
||||||
|
* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
|
||||||
|
* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
|
||||||
|
* [#459](https://github.com/shlinkio/shlink-web-client/issues/459) Added new list mode to display tags.
|
||||||
|
|
||||||
|
The mode is optional, and you can toggle between the classic cards mode or the new list mode whenever you want.
|
||||||
|
|
||||||
|
You can also configure the default mode from settings.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
||||||
|
* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand.
|
||||||
|
* [#409](https://github.com/shlinkio/shlink-web-client/issues/409) Increased required code coverage and added hard threshold check.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* [#491](https://github.com/shlinkio/shlink-web-client/issues/491) Dropped support for Shlink older than v2.4.0.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [3.2.1] - 2021-09-12
|
## [3.2.1] - 2021-09-12
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
@@ -18,9 +51,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* [#478](https://github.com/shlinkio/shlink-web-client/pull/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
* [#478](https://github.com/shlinkio/shlink-web-client/issues/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
||||||
* [#480](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed servers import on Chromium-based browsers when using windows.
|
* [#480](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed servers import on Chromium-based browsers when using windows.
|
||||||
* [#482](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
|
* [#482](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
|
||||||
|
|
||||||
|
|
||||||
## [3.2.0] - 2021-07-12
|
## [3.2.0] - 2021-07-12
|
||||||
@@ -32,16 +65,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
|
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
|
||||||
|
|
||||||
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
|
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
|
||||||
* [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
|
* [#440](https://github.com/shlinkio/shlink-web-client/issues/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
|
||||||
* [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
|
* [#431](https://github.com/shlinkio/shlink-web-client/issues/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
|
||||||
* [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
|
* [#430](https://github.com/shlinkio/shlink-web-client/issues/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
|
||||||
* [#450](https://github.com/shlinkio/shlink-web-client/pull/450) Improved landing page design.
|
* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design.
|
||||||
* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
|
* [#449](https://github.com/shlinkio/shlink-web-client/issues/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer.
|
* [#442](https://github.com/shlinkio/shlink-web-client/issues/442) Visits filtering now goes through the corresponding reducer.
|
||||||
* [#337](https://github.com/shlinkio/shlink-web-client/pull/337) Replaced moment.js with date-fns.
|
* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns.
|
||||||
* [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
|
* [#360](https://github.com/shlinkio/shlink-web-client/issues/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
@@ -50,7 +83,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* [#438](https://github.com/shlinkio/shlink-web-client/pull/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
|
* [#438](https://github.com/shlinkio/shlink-web-client/issues/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
|
||||||
|
|
||||||
|
|
||||||
## [3.1.2] - 2021-06-06
|
## [3.1.2] - 2021-06-06
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
coverageDirectory: '<rootDir>/coverage',
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.{js,ts,tsx}',
|
'src/**/*.{ts,tsx}',
|
||||||
'!src/registerServiceWorker.js',
|
'!src/*.{ts,tsx}',
|
||||||
'!src/index.ts',
|
|
||||||
'!src/reducers/index.ts',
|
'!src/reducers/index.ts',
|
||||||
'!src/**/provideServices.ts',
|
'!src/**/provideServices.ts',
|
||||||
'!src/container/*.ts',
|
'!src/container/*.ts',
|
||||||
],
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 85,
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 85,
|
||||||
|
},
|
||||||
|
},
|
||||||
resolver: 'jest-pnp-resolver',
|
resolver: 'jest-pnp-resolver',
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
'react-app-polyfill/jsdom',
|
'react-app-polyfill/jsdom',
|
||||||
|
|||||||
63
package-lock.json
generated
63
package-lock.json
generated
@@ -6363,15 +6363,6 @@
|
|||||||
"@babel/types": "^7.3.0"
|
"@babel/types": "^7.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/chart.js": {
|
|
||||||
"version": "2.9.31",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.31.tgz",
|
|
||||||
"integrity": "sha512-hzS6phN/kx3jClk3iYqEHNnYIRSi4RZrIGJ8CDLjgatpHoftCezvC44uqB3o3OUm9ftU1m7sHG8+RLyPTlACrA==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"moment": "^2.10.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@types/cheerio": {
|
"@types/cheerio": {
|
||||||
"version": "0.22.22",
|
"version": "0.22.22",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz",
|
||||||
@@ -10578,30 +10569,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"chart.js": {
|
"chart.js": {
|
||||||
"version": "2.9.4",
|
"version": "3.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz",
|
||||||
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
|
"integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ=="
|
||||||
"requires": {
|
|
||||||
"chartjs-color": "^2.1.0",
|
|
||||||
"moment": "^2.10.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"chartjs-color": {
|
|
||||||
"version": "2.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
|
|
||||||
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
|
|
||||||
"requires": {
|
|
||||||
"chartjs-color-string": "^0.6.0",
|
|
||||||
"color-convert": "^1.9.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"chartjs-color-string": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
|
|
||||||
"requires": {
|
|
||||||
"color-name": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"check-types": {
|
"check-types": {
|
||||||
"version": "11.1.2",
|
"version": "11.1.2",
|
||||||
@@ -10957,6 +10927,7 @@
|
|||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz",
|
"resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
"integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=",
|
"integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=",
|
||||||
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"color-name": "1.1.3"
|
"color-name": "1.1.3"
|
||||||
},
|
},
|
||||||
@@ -10964,14 +10935,16 @@
|
|||||||
"color-name": {
|
"color-name": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz",
|
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz",
|
||||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
|
||||||
|
"dev": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"color-name": {
|
"color-name": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI="
|
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"color-string": {
|
"color-string": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
@@ -19000,11 +18973,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"moment": {
|
|
||||||
"version": "2.29.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
|
||||||
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
|
|
||||||
},
|
|
||||||
"moo": {
|
"moo": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz",
|
||||||
@@ -24570,18 +24538,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"react-chartjs-2": {
|
"react-chartjs-2": {
|
||||||
"version": "2.11.1",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.4.tgz",
|
||||||
"integrity": "sha512-G7cNq/n2Bkh/v4vcI+GKx7Q1xwZexKYhOSj2HmrFXlvNeaURWXun6KlOUpEQwi1cv9Tgs4H3kGywDWMrX2kxfA==",
|
"integrity": "sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"lodash": "^4.17.19",
|
"lodash": "^4.17.19"
|
||||||
"prop-types": "^15.7.2"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash": {
|
"lodash": {
|
||||||
"version": "4.17.20",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -7,16 +7,18 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:css && npm run lint:js",
|
"lint": "npm run lint:css && npm run lint:js",
|
||||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||||
|
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||||
|
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||||
"lint:css:fix": "npm run lint:css -- --fix",
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"start": "node scripts/start.js",
|
"start": "node scripts/start.js",
|
||||||
"serve:build": "serve ./build",
|
"serve:build": "serve ./build",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js",
|
||||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||||
|
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
"bootstrap": "^4.6.0",
|
"bootstrap": "^4.6.0",
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^2.9.4",
|
"chart.js": "^3.5.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"compare-versions": "^3.6.0",
|
"compare-versions": "^3.6.0",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"qs": "^6.9.6",
|
"qs": "^6.9.6",
|
||||||
"ramda": "^0.27.1",
|
"ramda": "^0.27.1",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-chartjs-2": "^2.11.1",
|
"react-chartjs-2": "^3.0.4",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-copy-to-clipboard": "^5.0.2",
|
"react-copy-to-clipboard": "^5.0.2",
|
||||||
"react-datepicker": "^3.6.0",
|
"react-datepicker": "^3.6.0",
|
||||||
@@ -71,7 +73,6 @@
|
|||||||
"@stryker-mutator/jest-runner": "^5.0.0",
|
"@stryker-mutator/jest-runner": "^5.0.0",
|
||||||
"@stryker-mutator/typescript-checker": "^5.0.0",
|
"@stryker-mutator/typescript-checker": "^5.0.0",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^5.5.0",
|
||||||
"@types/chart.js": "^2.9.31",
|
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.2.11",
|
||||||
"@types/enzyme": "^3.10.8",
|
"@types/enzyme": "^3.10.8",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import qs from 'qs';
|
|
||||||
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 { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
@@ -16,7 +15,10 @@ import {
|
|||||||
ShlinkDomain,
|
ShlinkDomain,
|
||||||
ShlinkDomainsResponse,
|
ShlinkDomainsResponse,
|
||||||
ShlinkVisitsOverview,
|
ShlinkVisitsOverview,
|
||||||
|
ShlinkEditDomainRedirects,
|
||||||
|
ShlinkDomainRedirects,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
@@ -108,6 +110,11 @@ export default class ShlinkApiClient {
|
|||||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||||
|
|
||||||
|
public readonly editDomainRedirects = async (
|
||||||
|
domainRedirects: ShlinkEditDomainRedirects,
|
||||||
|
): Promise<ShlinkDomainRedirects> =>
|
||||||
|
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 {
|
try {
|
||||||
return await this.axios({
|
return await this.axios({
|
||||||
@@ -116,7 +123,7 @@ export default class ShlinkApiClient {
|
|||||||
headers: { 'X-Api-Key': this.apiKey },
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
params: rejectNilProps(query),
|
params: rejectNilProps(query),
|
||||||
data: body,
|
data: body,
|
||||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
paramsSerializer: stringifyQuery,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { response } = e;
|
const { response } = e;
|
||||||
|
|||||||
6
src/api/types/actions.ts
Normal file
6
src/api/types/actions.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Action } from 'redux';
|
||||||
|
import { ProblemDetailsError } from './index';
|
||||||
|
|
||||||
|
export interface ApiErrorAction extends Action<string> {
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
|
}
|
||||||
@@ -65,9 +65,20 @@ export interface ShlinkShortUrlData extends ShortUrlMeta {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomainRedirects {
|
||||||
|
baseUrlRedirect: string | null;
|
||||||
|
regular404Redirect: string | null;
|
||||||
|
invalidShortUrlRedirect: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShlinkDomain {
|
export interface ShlinkDomain {
|
||||||
domain: string;
|
domain: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkDomainsResponse {
|
export interface ShlinkDomainsResponse {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import './utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, FC } from 'react';
|
import { useEffect, FC } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
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';
|
||||||
import { changeThemeInMarkup } from './utils/theme';
|
import { changeThemeInMarkup } from '../utils/theme';
|
||||||
import { AppUpdateBanner } from './common/AppUpdateBanner';
|
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 {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle 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) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
faPen as editIcon,
|
faPen as editIcon,
|
||||||
faHome as overviewIcon,
|
faHome as overviewIcon,
|
||||||
|
faGlobe as domainsIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
@@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Location } from 'history';
|
import { Location } from 'history';
|
||||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||||
import { ServerWithId } from '../servers/data';
|
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||||
|
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||||
import './AsideMenu.scss';
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
export interface AsideMenuProps {
|
export interface AsideMenuProps {
|
||||||
selectedServer: ServerWithId;
|
selectedServer: SelectedServer;
|
||||||
className?: string;
|
className?: string;
|
||||||
showOnMobile?: boolean;
|
showOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -38,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 = selectedServer ? selectedServer.id : '';
|
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||||
|
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||||
const asideClass = classNames('aside-menu', {
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
@@ -49,30 +52,38 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||||||
<aside className={asideClass}>
|
<aside className={asideClass}>
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
<AsideMenuItem to={buildPath('/overview')}>
|
<AsideMenuItem to={buildPath('/overview')}>
|
||||||
<FontAwesomeIcon icon={overviewIcon} />
|
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||||
<span className="aside-menu__item-text">Overview</span>
|
<span className="aside-menu__item-text">Overview</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||||
<FontAwesomeIcon icon={listIcon} />
|
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||||
<span className="aside-menu__item-text">Create short URL</span>
|
<span className="aside-menu__item-text">Create short URL</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
<span className="aside-menu__item-text">Manage tags</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
|
{addManageDomainsLink && (
|
||||||
|
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||||
|
<span className="aside-menu__item-text">Manage domains</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
)}
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||||
<FontAwesomeIcon 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>
|
||||||
<DeleteServerButton
|
{isServerWithId(selectedServer) && (
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
<DeleteServerButton
|
||||||
textClassName="aside-menu__item-text"
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
server={selectedServer}
|
textClassName="aside-menu__item-text"
|
||||||
/>
|
server={selectedServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||||||
<Collapse navbar isOpen={isOpen}>
|
<Collapse navbar isOpen={isOpen}>
|
||||||
<Nav navbar className="ml-auto">
|
<Nav navbar className="ml-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||||
import { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features';
|
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { AsideMenuProps } from './AsideMenu';
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
@@ -22,6 +22,7 @@ const MenuLayout = (
|
|||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
Overview: FC,
|
Overview: FC,
|
||||||
EditShortUrl: FC,
|
EditShortUrl: FC,
|
||||||
|
ManageDomains: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
) => withSelectedServer(({ location, selectedServer }) => {
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||||
|
|
||||||
@@ -31,8 +32,8 @@ const MenuLayout = (
|
|||||||
return <ServerError />;
|
return <ServerError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
|
|
||||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||||
|
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
|
|
||||||
@@ -52,9 +53,10 @@ const MenuLayout = (
|
|||||||
<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} />
|
||||||
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
|
||||||
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
|
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
|
||||||
<Route
|
<Route
|
||||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
13
src/common/services/ImageDownloader.ts
Normal file
13
src/common/services/ImageDownloader.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { AxiosInstance } from 'axios';
|
||||||
|
import { saveUrl } from '../../utils/helpers/files';
|
||||||
|
|
||||||
|
export class ImageDownloader {
|
||||||
|
public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
|
||||||
|
|
||||||
|
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
||||||
|
const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(data);
|
||||||
|
|
||||||
|
saveUrl(this.window, url, filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,17 @@ import ErrorHandler from '../ErrorHandler';
|
|||||||
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
import { ImageDownloader } from './ImageDownloader';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
|
// Services
|
||||||
bottle.constant('window', (global as any).window);
|
bottle.constant('window', (global as any).window);
|
||||||
bottle.constant('console', global.console);
|
bottle.constant('console', global.console);
|
||||||
bottle.constant('axios', axios);
|
bottle.constant('axios', axios);
|
||||||
|
|
||||||
|
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||||
|
|
||||||
|
// Components
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||||
bottle.decorator('ScrollToTop', withRouter);
|
bottle.decorator('ScrollToTop', withRouter);
|
||||||
|
|
||||||
@@ -38,6 +43,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'ServerError',
|
'ServerError',
|
||||||
'Overview',
|
'Overview',
|
||||||
'EditShortUrl',
|
'EditShortUrl',
|
||||||
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|||||||
73
src/domains/DomainRow.tsx
Normal file
73
src/domains/DomainRow.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faBan as forbiddenIcon,
|
||||||
|
faCheck as defaultDomainIcon,
|
||||||
|
faEdit as editIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { OptionalString } from '../utils/utils';
|
||||||
|
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||||
|
|
||||||
|
interface DomainRowProps {
|
||||||
|
domain: ShlinkDomain;
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||||
|
<span className="text-muted">
|
||||||
|
{!fallback && <small>No redirect</small>}
|
||||||
|
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const DefaultDomain: FC = () => (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
|
||||||
|
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
|
||||||
|
const [ isOpen, toggle ] = useToggle();
|
||||||
|
const { domain: authority, isDefault, redirects } = domain;
|
||||||
|
const domainId = `domainEdit${authority.replace('.', '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="responsive-table__row">
|
||||||
|
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
|
||||||
|
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||||
|
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||||
|
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell" data-th="Regular 404 redirect">
|
||||||
|
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||||
|
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-right">
|
||||||
|
<span id={domainId}>
|
||||||
|
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
||||||
|
<FontAwesomeIcon icon={isDefault ? forbiddenIcon : editIcon} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
{isDefault && (
|
||||||
|
<UncontrolledTooltip target={domainId} placement="left">
|
||||||
|
Redirects for default domain cannot be edited here.
|
||||||
|
<br />
|
||||||
|
Use config options or env vars directly on the server.
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<EditDomainRedirectsModal
|
||||||
|
domain={domain}
|
||||||
|
isOpen={isOpen}
|
||||||
|
toggle={toggle}
|
||||||
|
editDomainRedirects={editDomainRedirects}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
71
src/domains/ManageDomains.tsx
Normal file
71
src/domains/ManageDomains.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import Message from '../utils/Message';
|
||||||
|
import { Result } from '../utils/Result';
|
||||||
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import SearchField from '../utils/SearchField';
|
||||||
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
|
import { DomainsList } from './reducers/domainsList';
|
||||||
|
import { DomainRow } from './DomainRow';
|
||||||
|
|
||||||
|
interface ManageDomainsProps {
|
||||||
|
listDomains: Function;
|
||||||
|
filterDomains: (searchTerm: string) => void;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ];
|
||||||
|
|
||||||
|
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
|
{ listDomains, domainsList, filterDomains, editDomainRedirects },
|
||||||
|
) => {
|
||||||
|
const { filteredDomains: domains, loading, error, errorData } = domainsList;
|
||||||
|
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listDomains();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Message loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Result type="error">
|
||||||
|
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
|
||||||
|
</Result>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCard>
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead className="responsive-table__header">
|
||||||
|
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<DomainRow
|
||||||
|
key={domain.domain}
|
||||||
|
domain={domain}
|
||||||
|
editDomainRedirects={editDomainRedirects}
|
||||||
|
defaultRedirects={defaultRedirects}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchField className="mb-3" onChange={filterDomains} />
|
||||||
|
{renderContent()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
|
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||||
|
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||||
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
|
||||||
|
interface EditDomainRedirectsModalProps {
|
||||||
|
domain: ShlinkDomain;
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||||
|
<FormGroupContainer
|
||||||
|
{...rest}
|
||||||
|
required={false}
|
||||||
|
type="url"
|
||||||
|
placeholder="No redirect"
|
||||||
|
className={isLast ? 'mb-0' : ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||||
|
{ isOpen, toggle, domain, editDomainRedirects },
|
||||||
|
) => {
|
||||||
|
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||||
|
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||||
|
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(
|
||||||
|
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||||
|
);
|
||||||
|
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||||
|
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||||
|
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||||
|
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||||
|
}).then(toggle));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||||
|
<InfoTooltip className="mr-2" placement="bottom">
|
||||||
|
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Base URL
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||||
|
<InfoTooltip className="mr-2" placement="bottom">
|
||||||
|
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||||
|
will be redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Regular 404
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||||
|
<InfoTooltip className="mr-2" placement="bottom">
|
||||||
|
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||||
|
redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Invalid short URL
|
||||||
|
</FormGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
|
||||||
|
<Button color="primary">Save</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
src/domains/reducers/domainRedirects.ts
Normal file
33
src/domains/reducers/domainRedirects.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
import { ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { parseApiError } from '../../api/utils';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
||||||
|
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
||||||
|
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export interface EditDomainRedirectsAction extends Action<string> {
|
||||||
|
domain: string;
|
||||||
|
redirects: ShlinkDomainRedirects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
|
domain: string,
|
||||||
|
domainRedirects: Partial<ShlinkDomainRedirects>,
|
||||||
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
|
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
||||||
|
const { editDomainRedirects } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
||||||
|
|
||||||
|
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,35 +1,63 @@
|
|||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkDomain } from '../../api/types';
|
import { ProblemDetailsError, ShlinkDomain, 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 { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
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';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface DomainsList {
|
export interface DomainsList {
|
||||||
domains: ShlinkDomain[];
|
domains: ShlinkDomain[];
|
||||||
|
filteredDomains: ShlinkDomain[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListDomainsAction extends Action<string> {
|
export interface ListDomainsAction extends Action<string> {
|
||||||
domains: ShlinkDomain[];
|
domains: ShlinkDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FilterDomainsAction extends Action<string> {
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: DomainsList = {
|
const initialState: DomainsList = {
|
||||||
domains: [],
|
domains: [],
|
||||||
|
filteredDomains: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<DomainsList, ListDomainsAction>({
|
export type DomainsCombinedAction = ListDomainsAction
|
||||||
|
& ApiErrorAction
|
||||||
|
& FilterDomainsAction
|
||||||
|
& EditDomainRedirectsAction;
|
||||||
|
|
||||||
|
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||||
|
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
|
||||||
|
|
||||||
|
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
|
||||||
|
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||||
|
...state,
|
||||||
|
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||||
|
}),
|
||||||
|
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
||||||
|
...state,
|
||||||
|
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
|
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
|
}),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
@@ -44,6 +72,8 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
|||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: LIST_DOMAINS_ERROR });
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { listDomains } from '../reducers/domainsList';
|
import { filterDomains, listDomains } from '../reducers/domainsList';
|
||||||
import { DomainSelector } from '../DomainSelector';
|
import { DomainSelector } from '../DomainSelector';
|
||||||
|
import { ManageDomains } from '../ManageDomains';
|
||||||
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||||
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
|
bottle.decorator('ManageDomains', connect(
|
||||||
|
[ 'domainsList' ],
|
||||||
|
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
||||||
|
));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||||
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
@import './common/react-tag-autocomplete.scss';
|
@import './common/react-tag-autocomplete.scss';
|
||||||
@import './theme/theme';
|
@import './theme/theme';
|
||||||
|
@import './utils/table/ResponsiveTable';
|
||||||
|
@import './utils/StickyCardPaginator';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export class Topics {
|
export class Topics {
|
||||||
public static visits = () => 'https://shlink.io/new-visit';
|
public static readonly visits = 'https://shlink.io/new-visit';
|
||||||
|
|
||||||
public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
||||||
|
|
||||||
public static orphanVisits = () => 'https://shlink.io/new-orphan-visit';
|
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className={className} onClick={showModal}>
|
<span className={className} onClick={showModal}>
|
||||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -55,14 +55,7 @@ export const Overview = (
|
|||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
<Card className="overview__card mb-3" body>
|
<Card className="overview__card mb-3" body>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
||||||
<CardText tag="h2">
|
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
|
||||||
<ForServerVersion minVersion="2.2.0">
|
|
||||||
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
|
||||||
</ForServerVersion>
|
|
||||||
<ForServerVersion maxVersion="2.1.*">
|
|
||||||
<small className="text-muted"><i>Shlink 2.2 is needed</i></small>
|
|
||||||
</ForServerVersion>
|
|
||||||
</CardText>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
@@ -120,4 +113,4 @@ export const Overview = (
|
|||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ export const isReachableServer = (server: SelectedServer): server is ReachableSe
|
|||||||
|
|
||||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||||
!!server?.hasOwnProperty('serverNotFound');
|
!!server?.hasOwnProperty('serverNotFound');
|
||||||
|
|
||||||
|
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||||
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
import { SimpleCard } from '../../utils/SimpleCard';
|
import { SimpleCard } from '../../utils/SimpleCard';
|
||||||
@@ -11,6 +11,9 @@ interface ServerFormProps {
|
|||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||||
|
<FormGroupContainer {...props} labelClassName="create-server__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('');
|
||||||
const [ url, setUrl ] = useState('');
|
const [ url, setUrl ] = useState('');
|
||||||
@@ -26,9 +29,9 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
|||||||
return (
|
return (
|
||||||
<form className="server-form" onSubmit={handleSubmit}>
|
<form className="server-form" onSubmit={handleSubmit}>
|
||||||
<SimpleCard className="mb-3" title={title}>
|
<SimpleCard className="mb-3" title={title}>
|
||||||
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
|
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
||||||
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
|
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
||||||
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
|
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
<div className="text-right">{children}</div>
|
<div className="text-right">{children}</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { dissoc, 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 } from '../data';
|
||||||
import { saveCsv } from '../../utils/helpers/csv';
|
import { saveCsv } from '../../utils/helpers/files';
|
||||||
|
|
||||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const RealTimeUpdates = (
|
|||||||
placeholder="Immediate"
|
placeholder="Immediate"
|
||||||
disabled={!realTimeUpdates.enabled}
|
disabled={!realTimeUpdates.enabled}
|
||||||
value={intervalValue(realTimeUpdates.interval)}
|
value={intervalValue(realTimeUpdates.interval)}
|
||||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
||||||
/>
|
/>
|
||||||
{realTimeUpdates.enabled && (
|
{realTimeUpdates.enabled && (
|
||||||
<small className="form-text text-muted">
|
<small className="form-text text-muted">
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC,
|
|||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<SettingsSections
|
<SettingsSections
|
||||||
items={[
|
items={[
|
||||||
[ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
|
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
||||||
[ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|||||||
@@ -1,29 +1,62 @@
|
|||||||
import { FC } from 'react';
|
import { FC, ReactNode } from 'react';
|
||||||
import { FormGroup } from 'reactstrap';
|
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 { Settings, ShortUrlCreationSettings } from './reducers/settings';
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
|
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings';
|
||||||
|
|
||||||
interface ShortUrlCreationProps {
|
interface ShortUrlCreationProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
|
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||||
{ settings: { shortUrlCreation }, setShortUrlCreationSettings },
|
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
|
||||||
) => (
|
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
|
||||||
<SimpleCard title="Short URLs creation" className="h-100">
|
tagFilteringMode === 'includes'
|
||||||
<FormGroup className="mb-0">
|
? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</>
|
||||||
<ToggleSwitch
|
: <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>;
|
||||||
checked={shortUrlCreation?.validateUrls ?? false}
|
|
||||||
onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
|
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||||
>
|
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||||
By default, request validation on long URLs when creating new short URLs.
|
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
||||||
|
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCard title="Short URLs creation" className="h-100">
|
||||||
|
<FormGroup>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={shortUrlCreation.validateUrls ?? false}
|
||||||
|
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||||
|
>
|
||||||
|
By default, request validation on long URLs when creating new short URLs.
|
||||||
|
<small className="form-text text-muted">
|
||||||
|
The initial state of the <b>Validate URL</b> checkbox will
|
||||||
|
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||||
|
</small>
|
||||||
|
</ToggleSwitch>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="mb-0">
|
||||||
|
<label>Tag suggestions search mode:</label>
|
||||||
|
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
||||||
|
<DropdownItem
|
||||||
|
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
|
||||||
|
onClick={changeTagsFilteringMode('startsWith')}
|
||||||
|
>
|
||||||
|
{tagFilteringModeText('startsWith')}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
active={shortUrlCreation.tagFilteringMode === 'includes'}
|
||||||
|
onClick={changeTagsFilteringMode('includes')}
|
||||||
|
>
|
||||||
|
{tagFilteringModeText('includes')}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
<small className="form-text text-muted">
|
<small className="form-text text-muted">
|
||||||
The initial state of the <b>Validate URL</b> checkbox will
|
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
|
||||||
be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
|
|
||||||
</small>
|
</small>
|
||||||
</ToggleSwitch>
|
</FormGroup>
|
||||||
</FormGroup>
|
</SimpleCard>
|
||||||
</SimpleCard>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
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 './UserInterface.scss';
|
||||||
|
|
||||||
@@ -14,17 +17,28 @@ interface UserInterfaceProps {
|
|||||||
|
|
||||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||||
<SimpleCard title="User interface" className="h-100">
|
<SimpleCard title="User interface" className="h-100">
|
||||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
<FormGroup>
|
||||||
<ToggleSwitch
|
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||||
checked={ui?.theme === 'dark'}
|
<ToggleSwitch
|
||||||
onChange={(useDarkTheme) => {
|
checked={ui?.theme === 'dark'}
|
||||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
onChange={(useDarkTheme) => {
|
||||||
|
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||||
|
|
||||||
setUiSettings({ theme });
|
setUiSettings({ ...ui, theme });
|
||||||
changeThemeInMarkup(theme);
|
changeThemeInMarkup(theme);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use dark theme.
|
Use dark theme.
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,17 +12,23 @@ export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface RealTimeUpdatesSettings {
|
export interface RealTimeUpdatesSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
interval?: number;
|
interval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TagFilteringMode = 'startsWith' | 'includes';
|
||||||
|
|
||||||
export interface ShortUrlCreationSettings {
|
export interface ShortUrlCreationSettings {
|
||||||
validateUrls: boolean;
|
validateUrls: boolean;
|
||||||
|
tagFilteringMode?: TagFilteringMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TagsMode = 'cards' | 'list';
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
tagsMode?: TagsMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsSettings {
|
export interface VisitsSettings {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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 } from '../utils/helpers/pagination';
|
||||||
import { ShlinkPaginator } from '../api/types';
|
import { ShlinkPaginator } from '../api/types';
|
||||||
import './Paginator.scss';
|
|
||||||
|
|
||||||
interface PaginatorProps {
|
interface PaginatorProps {
|
||||||
paginator?: ShlinkPaginator;
|
paginator?: ShlinkPaginator;
|
||||||
@@ -33,7 +32,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination className="short-urls-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
|
previous
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="search-bar-container">
|
<div className="search-bar-container">
|
||||||
<SearchField
|
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
|
||||||
onChange={
|
|
||||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
|||||||
@@ -5,13 +5,7 @@ import { isEmpty, pipe, replace, trim } 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 {
|
import { supportsCrawlableVisits, supportsShortUrlTitle } from '../utils/helpers/features';
|
||||||
supportsCrawlableVisits,
|
|
||||||
supportsListingDomains,
|
|
||||||
supportsSettingShortCodeLength,
|
|
||||||
supportsShortUrlTitle,
|
|
||||||
supportsValidateUrl,
|
|
||||||
} from '../utils/helpers/features';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||||
import Checkbox from '../utils/Checkbox';
|
import Checkbox from '../utils/Checkbox';
|
||||||
@@ -43,7 +37,7 @@ const toDate = (date?: string | Date): Date | undefined => typeof date === 'stri
|
|||||||
export const ShortUrlForm = (
|
export const ShortUrlForm = (
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
DomainSelector: FC<DomainSelectorProps>,
|
DomainSelector: FC<DomainSelectorProps>,
|
||||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity
|
): 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 changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||||
@@ -102,17 +96,13 @@ export const ShortUrlForm = (
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const showDomainSelector = supportsListingDomains(selectedServer);
|
|
||||||
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
|
|
||||||
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', {
|
||||||
'col-sm-6': showCustomizeCard,
|
'col-sm-6': showCustomizeCard,
|
||||||
'col-sm-12': !showCustomizeCard,
|
'col-sm-12': !showCustomizeCard,
|
||||||
});
|
});
|
||||||
const showValidateUrl = supportsValidateUrl(selectedServer);
|
|
||||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||||
const showExtraValidationsCard = showValidateUrl || showCrawlableControl || !isEdit;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="short-url-form" onSubmit={submit}>
|
<form className="short-url-form" onSubmit={submit}>
|
||||||
@@ -139,22 +129,16 @@ export const ShortUrlForm = (
|
|||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||||
min: 4,
|
min: 4,
|
||||||
disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug),
|
disabled: hasValue(shortUrlData.customSlug),
|
||||||
...disableShortCodeLength && {
|
|
||||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
|
<FormGroup>
|
||||||
{showDomainSelector && (
|
<DomainSelector
|
||||||
<FormGroup>
|
value={shortUrlData.domain}
|
||||||
<DomainSelector
|
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||||
value={shortUrlData.domain}
|
/>
|
||||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
</FormGroup>
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
@@ -170,41 +154,37 @@ export const ShortUrlForm = (
|
|||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{showExtraValidationsCard && (
|
<SimpleCard title="Extra checks" className="mb-3">
|
||||||
<SimpleCard title="Extra checks" className="mb-3">
|
<ShortUrlFormCheckboxGroup
|
||||||
{showValidateUrl && (
|
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||||
<ShortUrlFormCheckboxGroup
|
checked={shortUrlData.validateUrl}
|
||||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
|
||||||
checked={shortUrlData.validateUrl}
|
>
|
||||||
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
|
Validate URL
|
||||||
|
</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 })}
|
||||||
>
|
>
|
||||||
Validate URL
|
Use existing URL if found
|
||||||
</ShortUrlFormCheckboxGroup>
|
</Checkbox>
|
||||||
)}
|
<UseExistingIfFoundInfoIcon />
|
||||||
{showCrawlableControl && (
|
</p>
|
||||||
<ShortUrlFormCheckboxGroup
|
)}
|
||||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
</SimpleCard>
|
||||||
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
|
|
||||||
</Checkbox>
|
|
||||||
<UseExistingIfFoundInfoIcon />
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</SimpleCard>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
|||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default ShortUrlsList;
|
export default ShortUrlsList;
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.short-urls-table__header {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-table__header-cell--with-action {
|
.short-urls-table__header-cell--with-action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={tableClasses}>
|
<table className={tableClasses}>
|
||||||
<thead className="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
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { FC, useMemo, useState } from 'react';
|
||||||
import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap';
|
import { Modal, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlModalProps } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { DropdownBtn } from '../../utils/DropdownBtn';
|
|
||||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
|
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||||
import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features';
|
import {
|
||||||
|
supportsQrCodeSizeInQuery,
|
||||||
|
supportsQrCodeMargin,
|
||||||
|
supportsQrErrorCorrection,
|
||||||
|
} from '../../utils/helpers/features';
|
||||||
|
import { ImageDownloader } from '../../common/services/ImageDownloader';
|
||||||
|
import { Versions } from '../../utils/helpers/version';
|
||||||
|
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||||
import './QrCodeModal.scss';
|
import './QrCodeModal.scss';
|
||||||
|
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||||
|
|
||||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => {
|
const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Versions>) => ( // eslint-disable-line
|
||||||
|
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
|
||||||
|
) => {
|
||||||
const [ size, setSize ] = useState(300);
|
const [ size, setSize ] = useState(300);
|
||||||
const [ margin, setMargin ] = useState(0);
|
const [ margin, setMargin ] = useState(0);
|
||||||
const [ format, setFormat ] = useState<QrCodeFormat>('png');
|
const [ format, setFormat ] = useState<QrCodeFormat>('png');
|
||||||
|
const [ errorCorrection, setErrorCorrection ] = useState<QrErrorCorrection>('L');
|
||||||
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
||||||
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
|
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
|
||||||
svgIsSupported: supportsQrCodeSvgFormat(selectedServer),
|
|
||||||
marginIsSupported: supportsQrCodeMargin(selectedServer),
|
marginIsSupported: supportsQrCodeMargin(selectedServer),
|
||||||
|
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
|
||||||
}), [ selectedServer ]);
|
}), [ selectedServer ]);
|
||||||
|
const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported;
|
||||||
const qrCodeUrl = useMemo(
|
const qrCodeUrl = useMemo(
|
||||||
() => buildQrCodeUrl(shortUrl, { size, format, margin }, capabilities),
|
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
|
||||||
[ shortUrl, size, format, margin, capabilities ],
|
[ shortUrl, size, format, margin, errorCorrection, capabilities ],
|
||||||
);
|
);
|
||||||
const totalSize = useMemo(() => size + margin, [ size, margin ]);
|
const totalSize = useMemo(() => size + margin, [ size, margin ]);
|
||||||
const modalSize = useMemo(() => {
|
const modalSize = useMemo(() => {
|
||||||
@@ -42,60 +55,61 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
|
|||||||
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<Row className="mb-2">
|
<Row>
|
||||||
<div
|
<FormGroup
|
||||||
className={classNames({
|
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
|
||||||
'col-md-4': capabilities.marginIsSupported && capabilities.svgIsSupported,
|
|
||||||
'col-md-6': (!capabilities.marginIsSupported && capabilities.svgIsSupported) || (capabilities.marginIsSupported && !capabilities.svgIsSupported),
|
|
||||||
'col-12': !capabilities.marginIsSupported && !capabilities.svgIsSupported,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<FormGroup>
|
<label className="mb-0">Size: {size}px</label>
|
||||||
<label className="mb-0">Size: {size}px</label>
|
<input
|
||||||
|
type="range"
|
||||||
|
className="form-control-range"
|
||||||
|
value={size}
|
||||||
|
step={10}
|
||||||
|
min={50}
|
||||||
|
max={1000}
|
||||||
|
onChange={(e) => setSize(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{capabilities.marginIsSupported && (
|
||||||
|
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||||
|
<label className="mb-0">Margin: {margin}px</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="form-control-range"
|
className="form-control-range"
|
||||||
value={size}
|
value={margin}
|
||||||
step={10}
|
step={1}
|
||||||
min={50}
|
min={0}
|
||||||
max={1000}
|
max={100}
|
||||||
onChange={(e) => setSize(Number(e.target.value))}
|
onChange={(e) => setMargin(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</div>
|
|
||||||
{capabilities.marginIsSupported && (
|
|
||||||
<div className={capabilities.svgIsSupported ? 'col-md-4' : 'col-md-6'}>
|
|
||||||
<FormGroup>
|
|
||||||
<label className="mb-0">Margin: {margin}px</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="form-control-range"
|
|
||||||
value={margin}
|
|
||||||
step={1}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
onChange={(e) => setMargin(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{capabilities.svgIsSupported && (
|
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||||
<div className={capabilities.marginIsSupported ? 'col-md-4' : 'col-md-6'}>
|
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||||
<DropdownBtn text={`Format (${format})`}>
|
</FormGroup>
|
||||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
{capabilities.errorCorrectionIsSupported && (
|
||||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
<FormGroup className="col-md-6">
|
||||||
</DropdownBtn>
|
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
|
||||||
</div>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div>QR code URL:</div>
|
|
||||||
<ExternalLink href={qrCodeUrl} />
|
<ExternalLink href={qrCodeUrl} />
|
||||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||||
</div>
|
</div>
|
||||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||||
<div className="mt-2">{size}x{size}</div>
|
<ForServerVersion minVersion="2.9.0">
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
|
||||||
|
>
|
||||||
|
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ForServerVersion>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { ChangeEvent, FC, useRef } from 'react';
|
import { ChangeEvent, FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import Checkbox from '../../utils/Checkbox';
|
import Checkbox from '../../utils/Checkbox';
|
||||||
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
|
||||||
interface ShortUrlFormCheckboxGroupProps {
|
interface ShortUrlFormCheckboxGroupProps {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
@@ -10,23 +8,6 @@ interface ShortUrlFormCheckboxGroupProps {
|
|||||||
infoTooltip?: string;
|
infoTooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => {
|
|
||||||
const ref = useRef<HTMLElement | null>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
ref.current = el;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={infoIcon} />
|
|
||||||
</span>
|
|
||||||
<UncontrolledTooltip target={(() => ref.current) as any} placement="right">{tooltip}</UncontrolledTooltip>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||||
{ children, infoTooltip, checked, onChange },
|
{ children, infoTooltip, checked, onChange },
|
||||||
) => (
|
) => (
|
||||||
@@ -34,6 +15,6 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
|||||||
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
||||||
{children}
|
{children}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
{infoTooltip && <InfoTooltip tooltip={infoTooltip} />}
|
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,8 @@
|
|||||||
@import '../../utils/base';
|
@import '../../utils/base';
|
||||||
@import '../../utils/mixins/vertical-align';
|
@import '../../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.short-urls-row {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-row__cell.short-urls-row__cell {
|
.short-urls-row__cell.short-urls-row__cell {
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
padding: .5rem;
|
|
||||||
font-size: .9rem;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: attr(data-th);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
position: absolute;
|
|
||||||
top: 3.5px;
|
|
||||||
right: .5rem;
|
|
||||||
width: auto;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-row__cell--break {
|
.short-urls-row__cell--break {
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ const ShortUrlsRow = (
|
|||||||
}, [ shortUrl.visitsCount ]);
|
}, [ shortUrl.visitsCount ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="short-urls-row">
|
<tr className="responsive-table__row">
|
||||||
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
|
||||||
<Time date={shortUrl.dateCreated} />
|
<Time date={shortUrl.dateCreated} />
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell" data-th="Short URL: ">
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||||
<span className="indivisible short-urls-row__cell--relative">
|
<span className="indivisible short-urls-row__cell--relative">
|
||||||
<ExternalLink href={shortUrl.shortUrl} />
|
<ExternalLink href={shortUrl.shortUrl} />
|
||||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||||
@@ -64,16 +64,16 @@ const ShortUrlsRow = (
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}: `}>
|
<td className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}>
|
||||||
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||||
</td>
|
</td>
|
||||||
{shortUrl.title && (
|
{shortUrl.title && (
|
||||||
<td className="short-urls-row__cell short-urls-row__cell--break d-lg-none" data-th="Long URL: ">
|
<td className="short-urls-row__cell responsive-table__cell short-urls-row__cell--break d-lg-none" data-th="Long URL">
|
||||||
<ExternalLink href={shortUrl.longUrl} />
|
<ExternalLink href={shortUrl.longUrl} />
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
|
||||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits">
|
||||||
<ShortUrlVisitsCount
|
<ShortUrlVisitsCount
|
||||||
visitsCount={shortUrl.visitsCount}
|
visitsCount={shortUrl.visitsCount}
|
||||||
shortUrl={shortUrl}
|
shortUrl={shortUrl}
|
||||||
@@ -81,7 +81,7 @@ const ShortUrlsRow = (
|
|||||||
active={active}
|
active={active}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell">
|
<td className="responsive-table__cell short-urls-row__cell">
|
||||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
@import '../../utils/base';
|
@import '../../utils/base';
|
||||||
|
|
||||||
.short-urls-row-menu__dropdown-toggle:after {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-row-menu__dropdown-toggle--hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
||||||
color: $dangerColor;
|
color: $dangerColor;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
faChartPie as pieChartIcon,
|
faChartPie as pieChartIcon,
|
||||||
faEllipsisV as menuIcon,
|
|
||||||
faQrcode as qrIcon,
|
faQrcode as qrIcon,
|
||||||
faMinusCircle as deleteIcon,
|
faMinusCircle as deleteIcon,
|
||||||
faEdit as editIcon,
|
faEdit as editIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { ShortUrl, ShortUrlModalProps } from '../data';
|
import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||||
import './ShortUrlsRowMenu.scss';
|
import './ShortUrlsRowMenu.scss';
|
||||||
|
|
||||||
@@ -29,32 +29,27 @@ const ShortUrlsRowMenu = (
|
|||||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||||
</DropdownToggle>
|
</DropdownItem>
|
||||||
<DropdownMenu right>
|
|
||||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
|
||||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
|
||||||
</DropdownItem>
|
|
||||||
|
|
||||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem onClick={toggleQrCode}>
|
<DropdownItem onClick={toggleQrCode}>
|
||||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
<DropdownItem className="short-urls-row-menu__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} />
|
||||||
</DropdownMenu>
|
</DropdownBtnMenu>
|
||||||
</ButtonDropdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { DropdownBtn } from '../../../utils/DropdownBtn';
|
||||||
|
import { QrErrorCorrection } from '../../../utils/helpers/qrCodes';
|
||||||
|
|
||||||
|
interface QrErrorCorrectionDropdownProps {
|
||||||
|
errorCorrection: QrErrorCorrection;
|
||||||
|
setErrorCorrection: (errorCorrection: QrErrorCorrection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
|
||||||
|
{ errorCorrection, setErrorCorrection },
|
||||||
|
) => (
|
||||||
|
<DropdownBtn text={`Error correction (${errorCorrection})`}>
|
||||||
|
<DropdownItem active={errorCorrection === 'L'} onClick={() => setErrorCorrection('L')}>
|
||||||
|
<b>L</b>ow
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem active={errorCorrection === 'M'} onClick={() => setErrorCorrection('M')}>
|
||||||
|
<b>M</b>edium
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem active={errorCorrection === 'Q'} onClick={() => setErrorCorrection('Q')}>
|
||||||
|
<b>Q</b>uartile
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem active={errorCorrection === 'H'} onClick={() => setErrorCorrection('H')}>
|
||||||
|
<b>H</b>igh
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
|
);
|
||||||
16
src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Normal file
16
src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { DropdownBtn } from '../../../utils/DropdownBtn';
|
||||||
|
import { QrCodeFormat } from '../../../utils/helpers/qrCodes';
|
||||||
|
|
||||||
|
interface QrFormatDropdownProps {
|
||||||
|
format: QrCodeFormat;
|
||||||
|
setFormat: (format: QrCodeFormat) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, setFormat }) => (
|
||||||
|
<DropdownBtn text={`Format (${format})`}>
|
||||||
|
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||||
|
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
|
);
|
||||||
@@ -5,6 +5,7 @@ import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
|
|||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||||
@@ -24,17 +25,13 @@ export interface CreateShortUrlAction extends Action<string> {
|
|||||||
result: ShortUrl;
|
result: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateShortUrlFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlCreation = {
|
const initialState: ShortUrlCreation = {
|
||||||
result: null,
|
result: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & CreateShortUrlFailedAction>({
|
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & ApiErrorAction>({
|
||||||
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
||||||
@@ -53,7 +50,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
|||||||
|
|
||||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types';
|
|||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||||
@@ -24,17 +25,13 @@ export interface DeleteShortUrlAction extends Action<string> {
|
|||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteShortUrlErrorAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDeletion = {
|
const initialState: ShortUrlDeletion = {
|
||||||
shortCode: '',
|
shortCode: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & DeleteShortUrlErrorAction>({
|
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & ApiErrorAction>({
|
||||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||||
@@ -52,7 +49,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
|||||||
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) {
|
||||||
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { GetState } from '../../container/types';
|
|||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
||||||
@@ -25,16 +26,12 @@ export interface ShortUrlDetailAction extends Action<string> {
|
|||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlDetailFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDetail = {
|
const initialState: ShortUrlDetail = {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ShortUrlDetailFailedAction>({
|
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ApiErrorAction>({
|
||||||
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
||||||
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
||||||
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
||||||
@@ -54,6 +51,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
|||||||
|
|
||||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
|||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||||
@@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action<string> {
|
|||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlEditionFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlEdition = {
|
const initialState: ShortUrlEdition = {
|
||||||
saving: false,
|
saving: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({
|
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ApiErrorAction>({
|
||||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||||
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
||||||
@@ -59,7 +56,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
|||||||
|
|
||||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||||
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
|
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
|
||||||
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
|
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FC } from 'react';
|
import { FC, useEffect, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { Versions } from '../utils/helpers/version';
|
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||||
import TagBullet from './helpers/TagBullet';
|
import TagBullet from './helpers/TagBullet';
|
||||||
@@ -20,17 +19,24 @@ export interface TagCardProps {
|
|||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth;
|
||||||
|
|
||||||
const TagCard = (
|
const TagCard = (
|
||||||
DeleteTagConfirmModal: FC<TagModalProps>,
|
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||||
EditTagModal: FC<TagModalProps>,
|
EditTagModal: FC<TagModalProps>,
|
||||||
ForServerVersion: FC<Versions>,
|
|
||||||
colorGenerator: ColorGenerator,
|
colorGenerator: ColorGenerator,
|
||||||
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
|
) => ({ tag, tagStats, 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 titleRef = useRef<HTMLElement>();
|
||||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||||
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`;
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTruncated(titleRef.current)) {
|
||||||
|
displayTitle();
|
||||||
|
}
|
||||||
|
}, [ titleRef.current ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="tag-card">
|
<Card className="tag-card">
|
||||||
@@ -41,14 +47,15 @@ const TagCard = (
|
|||||||
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
<FontAwesomeIcon icon={editIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
<h5 className="tag-card__tag-title text-ellipsis">
|
<h5
|
||||||
|
className="tag-card__tag-title text-ellipsis"
|
||||||
|
title={hasTitle ? tag : undefined}
|
||||||
|
ref={(el) => {
|
||||||
|
titleRef.current = el ?? undefined;
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
||||||
<ForServerVersion minVersion="2.2.0">
|
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
|
||||||
</ForServerVersion>
|
|
||||||
<ForServerVersion maxVersion="2.1.*">
|
|
||||||
<Link to={shortUrlsLink}>{tag}</Link>
|
|
||||||
</ForServerVersion>
|
|
||||||
</h5>
|
</h5>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ const TagCard = (
|
|||||||
<Collapse isOpen={displayed}>
|
<Collapse isOpen={displayed}>
|
||||||
<CardBody className="tag-card__body">
|
<CardBody className="tag-card__body">
|
||||||
<Link
|
<Link
|
||||||
to={shortUrlsLink}
|
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>
|
||||||
|
|||||||
33
src/tags/TagsCards.tsx
Normal file
33
src/tags/TagsCards.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { splitEvery } from 'ramda';
|
||||||
|
import { Row } from 'reactstrap';
|
||||||
|
import { TagCardProps } from './TagCard';
|
||||||
|
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||||
|
|
||||||
|
const { ceil } = Math;
|
||||||
|
const TAGS_GROUPS_AMOUNT = 4;
|
||||||
|
|
||||||
|
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ tagsList, selectedServer }) => {
|
||||||
|
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||||
|
const tagsCount = tagsList.filteredTags.length;
|
||||||
|
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
|
{tagsGroups.map((group, index) => (
|
||||||
|
<div key={index} className="col-md-6 col-xl-3">
|
||||||
|
{group.map((tag) => (
|
||||||
|
<TagCard
|
||||||
|
key={tag}
|
||||||
|
tag={tag}
|
||||||
|
tagStats={tagsList.stats[tag]}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
displayed={displayedTag === tag}
|
||||||
|
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { Row } from 'reactstrap';
|
||||||
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';
|
||||||
@@ -7,33 +7,33 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
|||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
|
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
import { TagCardProps } from './TagCard';
|
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||||
|
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||||
const { ceil } = Math;
|
|
||||||
const TAGS_GROUPS_AMOUNT = 4;
|
|
||||||
|
|
||||||
export interface TagsListProps {
|
export interface TagsListProps {
|
||||||
filterTags: (searchTerm: string) => void;
|
filterTags: (searchTerm: string) => void;
|
||||||
forceListTags: Function;
|
forceListTags: Function;
|
||||||
tagsList: TagsListState;
|
tagsList: TagsListState;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
|
||||||
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
|
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||||
) => {
|
) => {
|
||||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
forceListTags();
|
forceListTags();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderContent = () => {
|
if (tagsList.loading) {
|
||||||
if (tagsList.loading) {
|
return <Message loading />;
|
||||||
return <Message loading />;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
if (tagsList.error) {
|
if (tagsList.error) {
|
||||||
return (
|
return (
|
||||||
<Result type="error">
|
<Result type="error">
|
||||||
@@ -42,40 +42,26 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsCount = tagsList.filteredTags.length;
|
if (tagsList.filteredTags.length < 1) {
|
||||||
|
|
||||||
if (tagsCount < 1) {
|
|
||||||
return <Message>No tags found</Message>;
|
return <Message>No tags found</Message>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
return mode === 'cards'
|
||||||
|
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
|
||||||
return (
|
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
|
||||||
<div className="row">
|
|
||||||
{tagsGroups.map((group, index) => (
|
|
||||||
<div key={index} className="col-md-6 col-xl-3">
|
|
||||||
{group.map((tag) => (
|
|
||||||
<TagCard
|
|
||||||
key={tag}
|
|
||||||
tag={tag}
|
|
||||||
tagStats={tagsList.stats[tag]}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
displayed={displayedTag === tag}
|
|
||||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
|
<SearchField className="mb-3" onChange={filterTags} />
|
||||||
|
<Row className="mb-3">
|
||||||
|
<div className="col-lg-6 offset-lg-6">
|
||||||
|
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default TagsList;
|
export default TagsList;
|
||||||
|
|||||||
23
src/tags/TagsModeDropdown.tsx
Normal file
23
src/tags/TagsModeDropdown.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
|
import { TagsMode } from '../settings/reducers/settings';
|
||||||
|
|
||||||
|
interface TagsModeDropdownProps {
|
||||||
|
mode: TagsMode;
|
||||||
|
onChange: (newMode: TagsMode) => void;
|
||||||
|
renderTitle?: (mode: TagsMode) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
|
||||||
|
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
|
||||||
|
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
|
||||||
|
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
|
||||||
|
<FontAwesomeIcon icon={listIcon} fixedWidth className="mr-1" /> List
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
|
);
|
||||||
65
src/tags/TagsTable.tsx
Normal file
65
src/tags/TagsTable.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { FC, useEffect, useRef } from 'react';
|
||||||
|
import { splitEvery } from 'ramda';
|
||||||
|
import { RouteChildrenProps } from 'react-router';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
|
import SimplePaginator from '../common/SimplePaginator';
|
||||||
|
import { useQueryState } from '../utils/helpers/hooks';
|
||||||
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
|
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||||
|
import { TagsTableRowProps } from './TagsTableRow';
|
||||||
|
|
||||||
|
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||||
|
|
||||||
|
export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsTableRowProps>) => (
|
||||||
|
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
||||||
|
) => {
|
||||||
|
const isFirstLoad = useRef(true);
|
||||||
|
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||||
|
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||||
|
const sortedTags = tagsList.filteredTags; // TODO Support sorting tags
|
||||||
|
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||||
|
const showPaginator = pages.length > 1;
|
||||||
|
const currentPage = pages[page - 1] ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
!isFirstLoad.current && setPage(1);
|
||||||
|
isFirstLoad.current = false;
|
||||||
|
}, [ tagsList.filteredTags ]);
|
||||||
|
useEffect(() => {
|
||||||
|
scrollTo(0, 0);
|
||||||
|
}, [ page ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead className="responsive-table__header">
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th className="text-lg-right">Short URLs</th>
|
||||||
|
<th className="text-lg-right">Visits</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||||
|
{currentPage.map((tag) => (
|
||||||
|
<TagsTableRow
|
||||||
|
key={tag}
|
||||||
|
tag={tag}
|
||||||
|
tagStats={tagsList.stats[tag]}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
colorGenerator={colorGenerator}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{showPaginator && (
|
||||||
|
<div className="sticky-card-paginator">
|
||||||
|
<SimplePaginator pagesCount={pages.length} currentPage={page} setCurrentPage={setPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
src/tags/TagsTableRow.tsx
Normal file
59
src/tags/TagsTableRow.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||||
|
import TagBullet from './helpers/TagBullet';
|
||||||
|
import { TagModalProps, TagStats } from './data';
|
||||||
|
|
||||||
|
export interface TagsTableRowProps {
|
||||||
|
tag: string;
|
||||||
|
tagStats?: TagStats;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
colorGenerator: ColorGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>) => (
|
||||||
|
{ tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps,
|
||||||
|
) => {
|
||||||
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
|
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="responsive-table__row">
|
||||||
|
<th className="responsive-table__cell" data-th="Tag">
|
||||||
|
<TagBullet tag={tag} colorGenerator={colorGenerator} /> {tag}
|
||||||
|
</th>
|
||||||
|
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
||||||
|
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}>
|
||||||
|
{prettify(tagStats?.shortUrlsCount ?? 0)}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
||||||
|
<Link to={`/server/${serverId}/tag/${tag}/visits`}>
|
||||||
|
{prettify(tagStats?.visitsCount ?? 0)}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-lg-right">
|
||||||
|
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
||||||
|
<DropdownItem onClick={toggleEdit}>
|
||||||
|
<FontAwesomeIcon icon={editIcon} fixedWidth className="mr-1" /> Edit
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={toggleDelete}>
|
||||||
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="mr-1" /> Delete
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtnMenu>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||||
|
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
src/tags/data/TagsListChildrenProps.ts
Normal file
7
src/tags/data/TagsListChildrenProps.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { TagsList as TagsListState } from '../reducers/tagsList';
|
||||||
|
import { SelectedServer } from '../../servers/data';
|
||||||
|
|
||||||
|
export interface TagsListChildrenProps {
|
||||||
|
tagsList: TagsListState;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||||
import { ChromePicker } from 'react-color';
|
import { ChromePicker } from 'react-color';
|
||||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -25,10 +25,12 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
const [ color, setColor ] = useState(getColorForKey(tag));
|
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||||
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
|
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
|
||||||
const { editing, error, errorData } = tagEdit;
|
const { editing, error, errorData } = tagEdit;
|
||||||
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
|
const saveTag = handleEventPreventingDefault(
|
||||||
.then(() => tagEdited(tag, newTagName, color))
|
async () => editTag(tag, newTagName, color)
|
||||||
.then(toggle)
|
.then(() => tagEdited(tag, newTagName, color))
|
||||||
.catch(() => {}));
|
.then(toggle)
|
||||||
|
.catch(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
||||||
@@ -47,13 +49,11 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||||
</Popover>
|
</Popover>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
|
||||||
value={newTagName}
|
value={newTagName}
|
||||||
placeholder="Tag"
|
placeholder="Tag"
|
||||||
required
|
required
|
||||||
className="form-control"
|
onChange={({ target }) => setNewTagName(target.value)}
|
||||||
onChange={(e) => setNewTagName(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,8 +64,8 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
|
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
||||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||||
|
import { Settings } from '../../settings/reducers/settings';
|
||||||
import { TagsList } from '../reducers/tagsList';
|
import { TagsList } from '../reducers/tagsList';
|
||||||
import TagBullet from './TagBullet';
|
import TagBullet from './TagBullet';
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
@@ -14,17 +15,19 @@ export interface TagsSelectorProps {
|
|||||||
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
||||||
listTags: Function;
|
listTags: Function;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
|
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
|
||||||
|
|
||||||
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||||
{ selectedTags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
{ selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps,
|
||||||
) => {
|
) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listTags();
|
listTags();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||||
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
|
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
|
||||||
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
|
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
|
||||||
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
|
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
|
||||||
@@ -42,15 +45,25 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
|
|||||||
suggestionComponent={ReactTagsSuggestion}
|
suggestionComponent={ReactTagsSuggestion}
|
||||||
allowNew
|
allowNew
|
||||||
addOnBlur
|
addOnBlur
|
||||||
placeholderText={placeholder}
|
placeholderText={placeholder ?? 'Add tags to the URL'}
|
||||||
minQueryLength={1}
|
minQueryLength={1}
|
||||||
|
delimiters={[ 'Enter', 'Tab', ',' ]}
|
||||||
|
suggestionsTransform={
|
||||||
|
searchMode === 'includes'
|
||||||
|
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDelete={(removedTagIndex) => {
|
onDelete={(removedTagIndex) => {
|
||||||
const tagsCopy = [ ...selectedTags ];
|
const tagsCopy = [ ...selectedTags ];
|
||||||
|
|
||||||
tagsCopy.splice(removedTagIndex, 1);
|
tagsCopy.splice(removedTagIndex, 1);
|
||||||
onChange(tagsCopy);
|
onChange(tagsCopy);
|
||||||
}}
|
}}
|
||||||
onAddition={({ name: newTag }) => onChange([ ...selectedTags, newTag.toLowerCase() ])}
|
onAddition={({ name: newTag }) => onChange(
|
||||||
|
// * Avoid duplicated tags (thanks to the Set),
|
||||||
|
// * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
|
||||||
|
[ ...new Set([ ...selectedTags, ...newTag.toLowerCase().split(',') ]) ],
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GetState } from '../../container/types';
|
|||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||||
@@ -22,16 +23,12 @@ export interface DeleteTagAction extends Action<string> {
|
|||||||
tag: string;
|
tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteTagFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TagDeletion = {
|
const initialState: TagDeletion = {
|
||||||
deleting: false,
|
deleting: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagDeletion, DeleteTagFailedAction>({
|
export default buildReducer<TagDeletion, ApiErrorAction>({
|
||||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
||||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
||||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
||||||
@@ -48,7 +45,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
|
|||||||
await deleteTags([ tag ]);
|
await deleteTags([ tag ]);
|
||||||
dispatch({ type: DELETE_TAG });
|
dispatch({ type: DELETE_TAG });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator';
|
|||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||||
@@ -29,10 +30,6 @@ export interface EditTagAction extends Action<string> {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditTagFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TagEdition = {
|
const initialState: TagEdition = {
|
||||||
oldName: '',
|
oldName: '',
|
||||||
newName: '',
|
newName: '',
|
||||||
@@ -40,7 +37,7 @@ const initialState: TagEdition = {
|
|||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagEdition, EditTagAction & EditTagFailedAction>({
|
export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
|
||||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
||||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
||||||
[EDIT_TAG]: (_, action) => ({
|
[EDIT_TAG]: (_, action) => ({
|
||||||
@@ -63,7 +60,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
|
|||||||
colorGenerator.setColorForKey(newName, color);
|
colorGenerator.setColorForKey(newName, color);
|
||||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
|||||||
import { CreateVisit, Stats } from '../../visits/types';
|
import { CreateVisit, Stats } from '../../visits/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { TagStats } from '../data';
|
import { TagStats } from '../data';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
||||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
||||||
|
|
||||||
@@ -34,20 +35,16 @@ interface ListTagsAction extends Action<string> {
|
|||||||
stats: TagsStatsMap;
|
stats: TagsStatsMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListTagsFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterTagsAction extends Action<string> {
|
interface FilterTagsAction extends Action<string> {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTagsCombinedAction = ListTagsAction
|
type TagsCombinedAction = ListTagsAction
|
||||||
& DeleteTagAction
|
& DeleteTagAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& EditTagAction
|
& EditTagAction
|
||||||
& FilterTagsAction
|
& FilterTagsAction
|
||||||
& ListTagsFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -83,7 +80,7 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
|
|||||||
}, {}),
|
}, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default buildReducer<TagsList, ListTagsCombinedAction>({
|
export default buildReducer<TagsList, TagsCombinedAction>({
|
||||||
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
||||||
@@ -130,7 +127,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
|||||||
|
|
||||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<ListTagsFailedAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Bottle, { IContainer } from 'bottlejs';
|
import Bottle, { IContainer } from 'bottlejs';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import TagsSelector from '../helpers/TagsSelector';
|
import TagsSelector from '../helpers/TagsSelector';
|
||||||
import TagCard from '../TagCard';
|
import TagCard from '../TagCard';
|
||||||
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
||||||
@@ -8,20 +9,16 @@ import { filterTags, listTags } from '../reducers/tagsList';
|
|||||||
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
||||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
import { TagsCards } from '../TagsCards';
|
||||||
|
import { TagsTable } from '../TagsTable';
|
||||||
|
import { TagsTableRow } from '../TagsTableRow';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
|
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
|
||||||
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
|
bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ]));
|
||||||
|
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||||
'TagCard',
|
|
||||||
TagCard,
|
|
||||||
'DeleteTagConfirmModal',
|
|
||||||
'EditTagModal',
|
|
||||||
'ForServerVersion',
|
|
||||||
'ColorGenerator',
|
|
||||||
);
|
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
|
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
|
||||||
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
|
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
|
||||||
@@ -29,9 +26,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
||||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
||||||
|
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal');
|
||||||
|
|
||||||
|
bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow');
|
||||||
|
bottle.decorator('TagsTable', withRouter);
|
||||||
|
|
||||||
|
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
||||||
bottle.decorator('TagsList', connect(
|
bottle.decorator('TagsList', connect(
|
||||||
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
|
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
|
||||||
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
3
src/utils/DropdownBtnMenu.scss
Normal file
3
src/utils/DropdownBtnMenu.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.dropdown-btn-menu__dropdown-toggle:after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
20
src/utils/DropdownBtnMenu.tsx
Normal file
20
src/utils/DropdownBtnMenu.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import './DropdownBtnMenu.scss';
|
||||||
|
|
||||||
|
export interface DropdownBtnMenuProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
right?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, children, right = true }) => (
|
||||||
|
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||||
|
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
|
||||||
|
<FontAwesomeIcon icon={menuIcon} />
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu right={right}>{children}</DropdownMenu>
|
||||||
|
</ButtonDropdown>
|
||||||
|
);
|
||||||
@@ -1,29 +1,37 @@
|
|||||||
import { FC } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { InputType } from 'reactstrap/lib/Input';
|
import { InputType } from 'reactstrap/lib/Input';
|
||||||
|
|
||||||
interface FormGroupContainerProps {
|
export interface FormGroupContainerProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (newValue: string) => void;
|
onChange: (newValue: string) => void;
|
||||||
id?: string;
|
id?: string;
|
||||||
type?: InputType;
|
type?: InputType;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
labelClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
||||||
{ children, value, onChange, id = uuid(), type = 'text', required = true },
|
{ children, value, onChange, id, type, required, placeholder, className, labelClassName },
|
||||||
) => (
|
) => {
|
||||||
<div className="form-group">
|
const forId = useRef<string>(id ?? uuid());
|
||||||
<label htmlFor={id} className="create-server__label">
|
|
||||||
{children}:
|
return (
|
||||||
</label>
|
<div className={`form-group ${className ?? ''}`}>
|
||||||
<input
|
<label htmlFor={forId.current} className={labelClassName ?? ''}>
|
||||||
className="form-control"
|
{children}:
|
||||||
type={type}
|
</label>
|
||||||
id={id}
|
<input
|
||||||
value={value}
|
className="form-control"
|
||||||
required={required}
|
type={type ?? 'text'}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
id={forId.current}
|
||||||
/>
|
value={value}
|
||||||
</div>
|
required={required ?? true}
|
||||||
);
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
26
src/utils/InfoTooltip.tsx
Normal file
26
src/utils/InfoTooltip.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { FC, useRef } from 'react';
|
||||||
|
import * as Popper from 'popper.js';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
|
||||||
|
interface InfoTooltipProps {
|
||||||
|
className?: string;
|
||||||
|
placement: Popper.Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
|
||||||
|
const ref = useRef<HTMLSpanElement | null>();
|
||||||
|
const refCallback = (el: HTMLSpanElement) => {
|
||||||
|
ref.current = el;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={className} ref={refCallback}>
|
||||||
|
<FontAwesomeIcon icon={infoIcon} />
|
||||||
|
</span>
|
||||||
|
<UncontrolledTooltip target={(() => ref.current) as any} placement={placement}>{children}</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,14 +10,11 @@ let timer: NodeJS.Timeout | null;
|
|||||||
interface SearchFieldProps {
|
interface SearchFieldProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchField = (
|
const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => {
|
||||||
{ onChange, className, placeholder = 'Search...', large = true, noBorder = false }: SearchFieldProps,
|
|
||||||
) => {
|
|
||||||
const [ searchTerm, setSearchTerm ] = useState('');
|
const [ searchTerm, setSearchTerm ] = useState('');
|
||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
@@ -43,7 +40,7 @@ const SearchField = (
|
|||||||
'form-control-lg': large,
|
'form-control-lg': large,
|
||||||
'search-field__input--no-border': noBorder,
|
'search-field__input--no-border': noBorder,
|
||||||
})}
|
})}
|
||||||
placeholder={placeholder}
|
placeholder="Search..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => searchTermChanged(e.target.value)}
|
onChange={(e) => searchTermChanged(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.short-urls-paginator {
|
.sticky-card-paginator {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--primary-color-alfa);
|
background-color: var(--primary-color-alfa);
|
||||||
@@ -1,30 +1,18 @@
|
|||||||
import { ChangeEvent, FC } from 'react';
|
import { ActiveElement, ChartEvent, ChartType, TooltipItem } from 'chart.js';
|
||||||
import { ChartData, ChartTooltipItem } from 'chart.js';
|
|
||||||
import { prettify } from './numbers';
|
import { prettify } from './numbers';
|
||||||
|
|
||||||
export const pointerOnHover = ({ target }: ChangeEvent<HTMLElement>, chartElement: FC[]) => {
|
export const pointerOnHover = ({ native }: ChartEvent, [ firstElement ]: ActiveElement[]) => {
|
||||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
if (!native?.target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = native.target as HTMLCanvasElement;
|
||||||
|
|
||||||
|
canvas.style.cursor = firstElement ? 'pointer' : 'default';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const renderNonDoughnutChartLabel = (labelToPick: 'yLabel' | 'xLabel') => (
|
export const renderChartLabel = ({ dataset, formattedValue }: TooltipItem<ChartType>) =>
|
||||||
item: ChartTooltipItem,
|
`${dataset.label}: ${prettify(formattedValue)}`;
|
||||||
{ datasets }: ChartData,
|
|
||||||
) => {
|
|
||||||
const { datasetIndex } = item;
|
|
||||||
const value = item[labelToPick];
|
|
||||||
const datasetLabel = datasetIndex !== undefined && datasets?.[datasetIndex]?.label || '';
|
|
||||||
|
|
||||||
return `${datasetLabel}: ${prettify(Number(value))}`;
|
export const renderPieChartLabel = ({ label, formattedValue }: TooltipItem<ChartType>) =>
|
||||||
};
|
`${label}: ${prettify(formattedValue)}`;
|
||||||
|
|
||||||
export const renderDoughnutChartLabel = (
|
|
||||||
{ datasetIndex, index }: ChartTooltipItem,
|
|
||||||
{ labels, datasets }: ChartData,
|
|
||||||
) => {
|
|
||||||
const datasetLabel = index !== undefined && labels?.[index] || '';
|
|
||||||
const value = datasetIndex !== undefined && index !== undefined
|
|
||||||
&& datasets?.[datasetIndex]?.data?.[index]
|
|
||||||
|| '';
|
|
||||||
|
|
||||||
return `${datasetLabel}: ${prettify(Number(value))}`; // eslint-disable-line @typescript-eslint/no-base-to-string
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -4,16 +4,6 @@ import { versionMatch, Versions } from './version';
|
|||||||
const serverMatchesVersions = (versions: Versions) => (selectedServer: SelectedServer): boolean =>
|
const serverMatchesVersions = (versions: Versions) => (selectedServer: SelectedServer): boolean =>
|
||||||
isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions);
|
isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions);
|
||||||
|
|
||||||
export const supportsSettingShortCodeLength = serverMatchesVersions({ minVersion: '2.1.0' });
|
|
||||||
|
|
||||||
export const supportsTagVisits = serverMatchesVersions({ minVersion: '2.2.0' });
|
|
||||||
|
|
||||||
export const supportsListingDomains = serverMatchesVersions({ minVersion: '2.4.0' });
|
|
||||||
|
|
||||||
export const supportsQrCodeSvgFormat = supportsListingDomains;
|
|
||||||
|
|
||||||
export const supportsValidateUrl = supportsListingDomains;
|
|
||||||
|
|
||||||
export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' });
|
export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' });
|
||||||
|
|
||||||
export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' });
|
export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' });
|
||||||
@@ -27,3 +17,7 @@ export const supportsTagsInPatch = supportsShortUrlTitle;
|
|||||||
export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });
|
export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });
|
||||||
|
|
||||||
export const supportsCrawlableVisits = supportsBotVisits;
|
export const supportsCrawlableVisits = supportsBotVisits;
|
||||||
|
|
||||||
|
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
|
||||||
|
|
||||||
|
export const supportsDomainRedirects = supportsQrErrorCorrection;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export const saveCsv = ({ document }: Window, csv: string, filename: string) => {
|
export const saveUrl = ({ document }: Window, url: string, filename: string) => {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
link.setAttribute('href', url);
|
link.setAttribute('href', url);
|
||||||
link.setAttribute('download', filename);
|
link.setAttribute('download', filename);
|
||||||
@@ -10,3 +8,10 @@ export const saveCsv = ({ document }: Window, csv: string, filename: string) =>
|
|||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const saveCsv = (window: Window, csv: string, filename: string) => {
|
||||||
|
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
saveUrl(window, url, filename);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
||||||
|
import { parseQuery, stringifyQuery } from './query';
|
||||||
|
|
||||||
const DEFAULT_DELAY = 2000;
|
const DEFAULT_DELAY = 2000;
|
||||||
|
|
||||||
@@ -51,3 +52,17 @@ export const useSwipeable = (showSidebar: () => void, hideSidebar: () => void) =
|
|||||||
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useQueryState = <T>(paramName: string, initialState: T): [ T, (newValue: T) => void ] => {
|
||||||
|
const [ value, setValue ] = useState(initialState);
|
||||||
|
const setValueWithLocation = (value: T) => {
|
||||||
|
const { location, history } = window;
|
||||||
|
const query = parseQuery<any>(location.search);
|
||||||
|
|
||||||
|
query[paramName] = value;
|
||||||
|
history.pushState(null, '', `${location.pathname}?${stringifyQuery(query)}`);
|
||||||
|
setValue(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [ value, setValueWithLocation ];
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ const TEN_ROUNDING_NUMBER = 10;
|
|||||||
const { ceil } = Math;
|
const { ceil } = Math;
|
||||||
const formatter = new Intl.NumberFormat('en-US');
|
const formatter = new Intl.NumberFormat('en-US');
|
||||||
|
|
||||||
export const prettify = (number: number) => formatter.format(number);
|
export const prettify = (number: number | string) => formatter.format(Number(number));
|
||||||
|
|
||||||
export const roundTen = (number: number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
|
export const roundTen = (number: number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
|
||||||
|
|||||||
@@ -3,28 +3,32 @@ import { stringifyQuery } from './query';
|
|||||||
|
|
||||||
export interface QrCodeCapabilities {
|
export interface QrCodeCapabilities {
|
||||||
useSizeInPath: boolean;
|
useSizeInPath: boolean;
|
||||||
svgIsSupported: boolean;
|
|
||||||
marginIsSupported: boolean;
|
marginIsSupported: boolean;
|
||||||
|
errorCorrectionIsSupported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QrCodeFormat = 'svg' | 'png';
|
export type QrCodeFormat = 'svg' | 'png';
|
||||||
|
|
||||||
|
export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H';
|
||||||
|
|
||||||
export interface QrCodeOptions {
|
export interface QrCodeOptions {
|
||||||
size: number;
|
size: number;
|
||||||
format: QrCodeFormat;
|
format: QrCodeFormat;
|
||||||
margin: number;
|
margin: number;
|
||||||
|
errorCorrection: QrErrorCorrection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildQrCodeUrl = (
|
export const buildQrCodeUrl = (
|
||||||
shortUrl: string,
|
shortUrl: string,
|
||||||
{ size, format, margin }: QrCodeOptions,
|
{ size, format, margin, errorCorrection }: QrCodeOptions,
|
||||||
{ useSizeInPath, svgIsSupported, marginIsSupported }: QrCodeCapabilities,
|
{ useSizeInPath, marginIsSupported, errorCorrectionIsSupported }: QrCodeCapabilities,
|
||||||
): string => {
|
): string => {
|
||||||
const baseUrl = `${shortUrl}/qr-code${useSizeInPath ? `/${size}` : ''}`;
|
const baseUrl = `${shortUrl}/qr-code${useSizeInPath ? `/${size}` : ''}`;
|
||||||
const query = stringifyQuery({
|
const query = stringifyQuery({
|
||||||
size: useSizeInPath ? undefined : size,
|
size: useSizeInPath ? undefined : size,
|
||||||
format: svgIsSupported ? format : undefined,
|
format,
|
||||||
margin: marginIsSupported && margin > 0 ? margin : undefined,
|
margin: marginIsSupported && margin > 0 ? margin : undefined,
|
||||||
|
errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`;
|
return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`;
|
||||||
|
|||||||
42
src/utils/table/ResponsiveTable.scss
Normal file
42
src/utils/table/ResponsiveTable.scss
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@import '../../utils/base';
|
||||||
|
|
||||||
|
.responsive-table__header {
|
||||||
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table__row {
|
||||||
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table__cell.responsive-table__cell {
|
||||||
|
vertical-align: middle !important;
|
||||||
|
|
||||||
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding: .5rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
|
||||||
|
&[data-th]:before {
|
||||||
|
content: attr(data-th) ': ';
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
position: absolute;
|
||||||
|
top: 3.5px;
|
||||||
|
right: .5rem;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,3 +43,7 @@ export type OptionalString = Optional<string>;
|
|||||||
export type RecursivePartial<T> = {
|
export type RecursivePartial<T> = {
|
||||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const nonEmptyValueOrNull = <T>(value: T): T | null => isEmpty(value) ? null : value;
|
||||||
|
|
||||||
|
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
||||||
|
|||||||
@@ -41,4 +41,4 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
|
|||||||
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
||||||
</VisitsStats>
|
</VisitsStats>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.orphanVisits() ]);
|
}, () => [ Topics.orphanVisits ]);
|
||||||
|
|||||||
@@ -43,6 +43,6 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
|
|||||||
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
||||||
</VisitsStats>
|
</VisitsStats>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default TagVisits;
|
export default TagVisits;
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
|||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { supportsBotVisits } from '../utils/helpers/features';
|
import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
import SortableBarGraph from './helpers/SortableBarGraph';
|
import LineChartCard from './charts/LineChartCard';
|
||||||
import GraphCard from './helpers/GraphCard';
|
|
||||||
import LineChartCard from './helpers/LineChartCard';
|
|
||||||
import VisitsTable from './VisitsTable';
|
import VisitsTable from './VisitsTable';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
||||||
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
|
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
|
||||||
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
||||||
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
||||||
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
||||||
|
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
||||||
|
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||||
import './VisitsStats.scss';
|
import './VisitsStats.scss';
|
||||||
|
|
||||||
export interface VisitsStatsProps {
|
export interface VisitsStatsProps {
|
||||||
@@ -173,13 +173,13 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||||||
|
|
||||||
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
|
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
|
||||||
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||||
<GraphCard title="Operating systems" stats={os} />
|
<DoughnutChartCard title="Operating systems" stats={os} />
|
||||||
</div>
|
</div>
|
||||||
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||||
<GraphCard title="Browsers" stats={browsers} />
|
<DoughnutChartCard title="Browsers" stats={browsers} />
|
||||||
</div>
|
</div>
|
||||||
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
|
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Referrers"
|
title="Referrers"
|
||||||
stats={referrers}
|
stats={referrers}
|
||||||
withPagination={false}
|
withPagination={false}
|
||||||
@@ -194,7 +194,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{isOrphanVisits && (
|
{isOrphanVisits && (
|
||||||
<div className="mt-3 col-lg-6">
|
<div className="mt-3 col-lg-6">
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Visited URLs"
|
title="Visited URLs"
|
||||||
stats={visitedUrls}
|
stats={visitedUrls}
|
||||||
highlightedLabel={highlightedLabel}
|
highlightedLabel={highlightedLabel}
|
||||||
@@ -211,7 +211,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||||||
|
|
||||||
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
||||||
<div className="col-lg-6 mt-3">
|
<div className="col-lg-6 mt-3">
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Countries"
|
title="Countries"
|
||||||
stats={countries}
|
stats={countries}
|
||||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
||||||
@@ -224,7 +224,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 mt-3">
|
<div className="col-lg-6 mt-3">
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Cities"
|
title="Cities"
|
||||||
stats={cities}
|
stats={cities}
|
||||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.graph-card__footer--sticky {
|
.chart-card__footer--sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
16
src/visits/charts/ChartCard.tsx
Normal file
16
src/visits/charts/ChartCard.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
import './ChartCard.scss';
|
||||||
|
|
||||||
|
interface ChartCardProps {
|
||||||
|
title: Function | string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||||
|
<CardBody>{children}</CardBody>
|
||||||
|
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
72
src/visits/charts/DoughnutChart.tsx
Normal file
72
src/visits/charts/DoughnutChart.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { FC, useState, memo } from 'react';
|
||||||
|
import { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||||
|
import { keys, values } from 'ramda';
|
||||||
|
import { Doughnut } from 'react-chartjs-2';
|
||||||
|
import { renderPieChartLabel } from '../../utils/helpers/charts';
|
||||||
|
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../utils/theme';
|
||||||
|
import { Stats } from '../types';
|
||||||
|
import { DoughnutChartLegend } from './DoughnutChartLegend';
|
||||||
|
|
||||||
|
interface DoughnutChartProps {
|
||||||
|
stats: Stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateChartDatasets = (data: number[]): ChartDataset[] => [
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
backgroundColor: [
|
||||||
|
'#97BBCD',
|
||||||
|
'#F7464A',
|
||||||
|
'#46BFBD',
|
||||||
|
'#FDB45C',
|
||||||
|
'#949FB1',
|
||||||
|
'#57A773',
|
||||||
|
'#414066',
|
||||||
|
'#08B2E3',
|
||||||
|
'#B6C454',
|
||||||
|
'#DCDCDC',
|
||||||
|
'#463730',
|
||||||
|
],
|
||||||
|
borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const generateChartData = (labels: string[], data: number[]): ChartData => ({
|
||||||
|
labels,
|
||||||
|
datasets: generateChartDatasets(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DoughnutChart: FC<DoughnutChartProps> = memo(({ stats }) => {
|
||||||
|
const [ chartRef, setChartRef ] = useState<Chart | undefined>(); // Cannot use useRef here
|
||||||
|
const labels = keys(stats);
|
||||||
|
const data = values(stats);
|
||||||
|
|
||||||
|
const options: ChartOptions = {
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
intersect: true,
|
||||||
|
callbacks: { label: renderPieChartLabel },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const chartData = generateChartData(labels, data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12 col-md-7">
|
||||||
|
<Doughnut
|
||||||
|
height={300}
|
||||||
|
data={chartData}
|
||||||
|
options={options}
|
||||||
|
ref={(element) => {
|
||||||
|
setChartRef(element ?? undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-12 col-md-5">
|
||||||
|
{chartRef && <DoughnutChartLegend chart={chartRef} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
15
src/visits/charts/DoughnutChartCard.tsx
Normal file
15
src/visits/charts/DoughnutChartCard.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Stats } from '../types';
|
||||||
|
import { DoughnutChart } from './DoughnutChart';
|
||||||
|
import { ChartCard } from './ChartCard';
|
||||||
|
|
||||||
|
interface DoughnutChartCardProps {
|
||||||
|
title: string;
|
||||||
|
stats: Stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
|
||||||
|
<ChartCard title={title}>
|
||||||
|
<DoughnutChart stats={stats} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@import '../../utils/base';
|
@import '../../utils/base';
|
||||||
|
|
||||||
.default-chart__pie-chart-legend {
|
.doughnut-chart-legend {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -10,11 +10,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-chart__pie-chart-legend-item:not(:first-child) {
|
.doughnut-chart-legend__item:not(:first-child) {
|
||||||
margin-top: .3rem;
|
margin-top: .3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-chart__pie-chart-legend-item-color {
|
.doughnut-chart-legend__item-color {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-chart__pie-chart-legend-item-text {
|
.doughnut-chart-legend__item-text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
28
src/visits/charts/DoughnutChartLegend.tsx
Normal file
28
src/visits/charts/DoughnutChartLegend.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Chart } from 'chart.js';
|
||||||
|
import './DoughnutChartLegend.scss';
|
||||||
|
|
||||||
|
interface DoughnutChartLegendProps {
|
||||||
|
chart: Chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
|
||||||
|
const { config } = chart;
|
||||||
|
const { labels = [], datasets = [] } = config.data ?? {};
|
||||||
|
const [{ backgroundColor: colors }] = datasets;
|
||||||
|
const { defaultColor } = config.options ?? {} as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="doughnut-chart-legend">
|
||||||
|
{(labels as string[]).map((label, index) => (
|
||||||
|
<li key={label} className="doughnut-chart-legend__item d-flex">
|
||||||
|
<div
|
||||||
|
className="doughnut-chart-legend__item-color"
|
||||||
|
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
|
||||||
|
/>
|
||||||
|
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
131
src/visits/charts/HorizontalBarChart.tsx
Normal file
131
src/visits/charts/HorizontalBarChart.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||||
|
import { keys, values } from 'ramda';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||||
|
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||||
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
|
import { Stats } from '../types';
|
||||||
|
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../utils/theme';
|
||||||
|
|
||||||
|
export interface HorizontalBarChartProps {
|
||||||
|
stats: Stats;
|
||||||
|
max?: number;
|
||||||
|
highlightedStats?: Stats;
|
||||||
|
highlightedLabel?: string;
|
||||||
|
onClick?: (label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;
|
||||||
|
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
|
||||||
|
const determineHeight = (labels: string[]): number | undefined => labels.length > 20 ? labels.length * 10 : undefined;
|
||||||
|
|
||||||
|
const generateChartDatasets = (
|
||||||
|
data: number[],
|
||||||
|
highlightedData: number[],
|
||||||
|
highlightedLabel?: string,
|
||||||
|
): ChartDataset[] => {
|
||||||
|
const mainDataset: ChartDataset = {
|
||||||
|
data,
|
||||||
|
label: highlightedLabel ? 'Non-selected' : 'Visits',
|
||||||
|
backgroundColor: MAIN_COLOR_ALPHA,
|
||||||
|
borderColor: MAIN_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (highlightedData.every((value) => value === 0)) {
|
||||||
|
return [ mainDataset ];
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightedDataset: ChartDataset = {
|
||||||
|
label: highlightedLabel ?? 'Selected',
|
||||||
|
data: highlightedData,
|
||||||
|
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
|
||||||
|
borderColor: HIGHLIGHTED_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [ mainDataset, highlightedDataset ];
|
||||||
|
};
|
||||||
|
const generateChartData = (
|
||||||
|
labels: string[],
|
||||||
|
data: number[],
|
||||||
|
highlightedData: number[],
|
||||||
|
highlightedLabel?: string,
|
||||||
|
): ChartData => ({
|
||||||
|
labels,
|
||||||
|
datasets: generateChartDatasets(data, highlightedData, highlightedLabel),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ClickedCharts = [{ index: number }] | [];
|
||||||
|
const chartElementAtEvent = (labels: string[], onClick?: (label: string) => void) => ([ chart ]: ClickedCharts) => {
|
||||||
|
if (!onClick || !chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick(labels[chart.index]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HorizontalBarChart: FC<HorizontalBarChartProps> = (
|
||||||
|
{ stats, highlightedStats, highlightedLabel, onClick, max },
|
||||||
|
) => {
|
||||||
|
const labels = keys(stats).map(dropLabelIfHidden);
|
||||||
|
const data = values(
|
||||||
|
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
|
||||||
|
if (acc[highlightedKey]) {
|
||||||
|
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, { ...stats }),
|
||||||
|
);
|
||||||
|
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
|
||||||
|
|
||||||
|
const options: ChartOptions = {
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'y',
|
||||||
|
// Do not show tooltip on items with empty label when in a bar chart
|
||||||
|
filter: ({ label }) => label !== '',
|
||||||
|
callbacks: { label: renderChartLabel },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
stacked: true,
|
||||||
|
max,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
callback: prettify,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: { stacked: true },
|
||||||
|
},
|
||||||
|
onHover: pointerOnHover,
|
||||||
|
indexAxis: 'y',
|
||||||
|
};
|
||||||
|
const chartData = generateChartData(labels, data, highlightedData, highlightedLabel);
|
||||||
|
const height = determineHeight(labels);
|
||||||
|
|
||||||
|
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
|
||||||
|
const renderChartComponent = (customKey: string) => (
|
||||||
|
<Bar
|
||||||
|
key={`${height}_${customKey}`}
|
||||||
|
data={chartData}
|
||||||
|
options={options}
|
||||||
|
height={height}
|
||||||
|
getElementAtEvent={chartElementAtEvent(labels, onClick) as any}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||||
|
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||||
|
{highlightedStats !== undefined && renderChartComponent('with_stats')}
|
||||||
|
{highlightedStats === undefined && renderChartComponent('without_stats')}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,14 +21,14 @@ import {
|
|||||||
startOfISOWeek,
|
startOfISOWeek,
|
||||||
endOfISOWeek,
|
endOfISOWeek,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
import { ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||||
import { NormalizedVisit, Stats } from '../types';
|
import { NormalizedVisit, Stats } from '../types';
|
||||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { rangeOf } from '../../utils/utils';
|
import { rangeOf } from '../../utils/utils';
|
||||||
import ToggleSwitch from '../../utils/ToggleSwitch';
|
import ToggleSwitch from '../../utils/ToggleSwitch';
|
||||||
import { prettify } from '../../utils/helpers/numbers';
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
import { pointerOnHover, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
|
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||||
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
|
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
|
||||||
import './LineChartCard.scss';
|
import './LineChartCard.scss';
|
||||||
|
|
||||||
@@ -134,11 +134,11 @@ const generateLabelsAndGroupedVisits = (
|
|||||||
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
|
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateDataset = (data: number[], label: string, color: string): ChartDataSets => ({
|
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
|
||||||
label,
|
label,
|
||||||
data,
|
data,
|
||||||
fill: false,
|
fill: false,
|
||||||
lineTension: 0.2,
|
tension: 0.2,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
});
|
});
|
||||||
@@ -146,15 +146,15 @@ const generateDataset = (data: number[], label: string, color: string): ChartDat
|
|||||||
let selectedLabel: string | null = null;
|
let selectedLabel: string | null = null;
|
||||||
|
|
||||||
const chartElementAtEvent = (
|
const chartElementAtEvent = (
|
||||||
|
labels: string[],
|
||||||
datasetsByPoint: Record<string, NormalizedVisit[]>,
|
datasetsByPoint: Record<string, NormalizedVisit[]>,
|
||||||
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
|
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
|
||||||
) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
|
) => ([ chart ]: [{ index: number }]) => {
|
||||||
if (!setSelectedVisits || !chart) {
|
if (!setSelectedVisits || !chart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { _index: index, _chart: { data } } = chart;
|
const { index } = chart;
|
||||||
const { labels } = data as { labels: string[] };
|
|
||||||
|
|
||||||
if (selectedLabel === labels[index]) {
|
if (selectedLabel === labels[index]) {
|
||||||
setSelectedVisits([]);
|
setSelectedVisits([]);
|
||||||
@@ -183,42 +183,50 @@ const LineChartCard = (
|
|||||||
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
||||||
[ highlightedVisits, step, labels ],
|
[ highlightedVisits, step, labels ],
|
||||||
);
|
);
|
||||||
|
const generateChartDatasets = (): ChartDataset[] => {
|
||||||
|
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
|
||||||
|
|
||||||
const data: ChartData = {
|
if (highlightedVisits.length === 0) {
|
||||||
labels,
|
return [ mainDataset ];
|
||||||
datasets: [
|
}
|
||||||
generateDataset(groupedVisits, 'Visits', MAIN_COLOR),
|
|
||||||
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR),
|
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
|
||||||
].filter(Boolean) as ChartDataSets[],
|
|
||||||
|
return [ mainDataset, highlightedDataset ];
|
||||||
};
|
};
|
||||||
|
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
|
||||||
|
|
||||||
const options: ChartOptions = {
|
const options: ChartOptions = {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
legend: { display: false },
|
plugins: {
|
||||||
scales: {
|
legend: { display: false },
|
||||||
yAxes: [
|
tooltip: {
|
||||||
{
|
intersect: false,
|
||||||
ticks: {
|
axis: 'x',
|
||||||
beginAtZero: true,
|
callbacks: { label: renderChartLabel },
|
||||||
precision: 0,
|
|
||||||
callback: prettify,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
xAxes: [
|
|
||||||
{
|
|
||||||
scaleLabel: { display: true, labelString: STEPS_MAP[step] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tooltips: {
|
|
||||||
intersect: false,
|
|
||||||
axis: 'x',
|
|
||||||
callbacks: {
|
|
||||||
label: renderNonDoughnutChartLabel('yLabel'),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
callback: prettify,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: STEPS_MAP[step] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onHover: pointerOnHover,
|
||||||
};
|
};
|
||||||
|
const renderLineChart = () => (
|
||||||
|
<Line
|
||||||
|
data={generateChartData()}
|
||||||
|
options={options}
|
||||||
|
getElementAtEvent={chartElementAtEvent(labels, datasetsByPoint, setSelectedVisits) as any}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -245,11 +253,10 @@ const LineChartCard = (
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="line-chart-card__body">
|
<CardBody className="line-chart-card__body">
|
||||||
<Line
|
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||||
data={data}
|
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||||
options={options}
|
{highlightedVisits.length > 0 && renderLineChart()}
|
||||||
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
|
{highlightedVisits.length === 0 && renderLineChart()}
|
||||||
/>
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -1,25 +1,26 @@
|
|||||||
import { useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||||
|
import { OrderDir, rangeOf } from '../../utils/utils';
|
||||||
|
import SimplePaginator from '../../common/SimplePaginator';
|
||||||
|
import { roundTen } from '../../utils/helpers/numbers';
|
||||||
import SortingDropdown from '../../utils/SortingDropdown';
|
import SortingDropdown from '../../utils/SortingDropdown';
|
||||||
import PaginationDropdown from '../../utils/PaginationDropdown';
|
import PaginationDropdown from '../../utils/PaginationDropdown';
|
||||||
import { OrderDir, rangeOf } from '../../utils/utils';
|
|
||||||
import { roundTen } from '../../utils/helpers/numbers';
|
|
||||||
import SimplePaginator from '../../common/SimplePaginator';
|
|
||||||
import { Stats, StatsRow } from '../types';
|
import { Stats, StatsRow } from '../types';
|
||||||
import GraphCard from './GraphCard';
|
import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
|
||||||
import { DefaultChartProps } from './DefaultChart';
|
import { ChartCard } from './ChartCard';
|
||||||
|
|
||||||
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value; // eslint-disable-line @typescript-eslint/no-unsafe-return
|
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
|
||||||
const pickKeyFromPair = ([ key ]: StatsRow) => key;
|
title: Function | string;
|
||||||
const pickValueFromPair = ([ , value ]: StatsRow) => value;
|
|
||||||
|
|
||||||
interface SortableBarGraphProps extends DefaultChartProps {
|
|
||||||
sortingItems: Record<string, string>;
|
sortingItems: Record<string, string>;
|
||||||
withPagination?: boolean;
|
withPagination?: boolean;
|
||||||
extraHeaderContent?: Function;
|
extraHeaderContent?: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableBarGraph = ({
|
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value; // eslint-disable-line @typescript-eslint/no-unsafe-return
|
||||||
|
const pickKeyFromPair = ([ key ]: StatsRow) => key;
|
||||||
|
const pickValueFromPair = ([ , value ]: StatsRow) => value;
|
||||||
|
|
||||||
|
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||||
stats,
|
stats,
|
||||||
highlightedStats,
|
highlightedStats,
|
||||||
title,
|
title,
|
||||||
@@ -27,7 +28,7 @@ const SortableBarGraph = ({
|
|||||||
extraHeaderContent,
|
extraHeaderContent,
|
||||||
withPagination = true,
|
withPagination = true,
|
||||||
...rest
|
...rest
|
||||||
}: SortableBarGraphProps) => {
|
}) => {
|
||||||
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
|
||||||
orderField: undefined,
|
orderField: undefined,
|
||||||
orderDir: undefined,
|
orderDir: undefined,
|
||||||
@@ -131,16 +132,11 @@ const SortableBarGraph = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GraphCard
|
<ChartCard
|
||||||
isBarChart
|
|
||||||
title={computeTitle}
|
title={computeTitle}
|
||||||
stats={currentPageStats}
|
|
||||||
highlightedStats={currentPageHighlightedStats}
|
|
||||||
footer={pagination}
|
footer={pagination}
|
||||||
max={max}
|
>
|
||||||
{...rest}
|
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
|
||||||
/>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SortableBarGraph;
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
|
||||||
import { keys, values } from 'ramda';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
|
||||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
|
||||||
import { Stats } from '../types';
|
|
||||||
import { prettify } from '../../utils/helpers/numbers';
|
|
||||||
import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
|
|
||||||
import {
|
|
||||||
HIGHLIGHTED_COLOR,
|
|
||||||
HIGHLIGHTED_COLOR_ALPHA,
|
|
||||||
isDarkThemeEnabled,
|
|
||||||
MAIN_COLOR,
|
|
||||||
MAIN_COLOR_ALPHA,
|
|
||||||
PRIMARY_DARK_COLOR,
|
|
||||||
PRIMARY_LIGHT_COLOR,
|
|
||||||
} from '../../utils/theme';
|
|
||||||
import './DefaultChart.scss';
|
|
||||||
|
|
||||||
export interface DefaultChartProps {
|
|
||||||
title: Function | string;
|
|
||||||
stats: Stats;
|
|
||||||
isBarChart?: boolean;
|
|
||||||
max?: number;
|
|
||||||
highlightedStats?: Stats;
|
|
||||||
highlightedLabel?: string;
|
|
||||||
onClick?: (label: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateGraphData = (
|
|
||||||
title: Function | string,
|
|
||||||
isBarChart: boolean,
|
|
||||||
labels: string[],
|
|
||||||
data: number[],
|
|
||||||
highlightedData?: number[],
|
|
||||||
highlightedLabel?: string,
|
|
||||||
): ChartData => ({
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
label: highlightedData ? 'Non-selected' : 'Visits',
|
|
||||||
data,
|
|
||||||
backgroundColor: isBarChart ? MAIN_COLOR_ALPHA : [
|
|
||||||
'#97BBCD',
|
|
||||||
'#F7464A',
|
|
||||||
'#46BFBD',
|
|
||||||
'#FDB45C',
|
|
||||||
'#949FB1',
|
|
||||||
'#57A773',
|
|
||||||
'#414066',
|
|
||||||
'#08B2E3',
|
|
||||||
'#B6C454',
|
|
||||||
'#DCDCDC',
|
|
||||||
'#463730',
|
|
||||||
],
|
|
||||||
borderColor: isBarChart ? MAIN_COLOR : isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
|
|
||||||
borderWidth: 2,
|
|
||||||
},
|
|
||||||
highlightedData && {
|
|
||||||
title,
|
|
||||||
label: highlightedLabel ?? 'Selected',
|
|
||||||
data: highlightedData,
|
|
||||||
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
|
|
||||||
borderColor: HIGHLIGHTED_COLOR,
|
|
||||||
borderWidth: 2,
|
|
||||||
},
|
|
||||||
].filter(Boolean) as ChartDataSets[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;
|
|
||||||
|
|
||||||
const determineHeight = (isBarChart: boolean, labels: string[]): number | undefined => {
|
|
||||||
if (!isBarChart) {
|
|
||||||
return 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isBarChart && labels.length > 20 ? labels.length * 8 : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPieChartLegend = ({ config }: Chart) => {
|
|
||||||
const { labels = [], datasets = [] } = config.data ?? {};
|
|
||||||
const { defaultColor } = config.options ?? {} as any;
|
|
||||||
const [{ backgroundColor: colors }] = datasets;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="default-chart__pie-chart-legend">
|
|
||||||
{labels.map((label, index) => (
|
|
||||||
<li key={label as string} className="default-chart__pie-chart-legend-item d-flex">
|
|
||||||
<div
|
|
||||||
className="default-chart__pie-chart-legend-item-color"
|
|
||||||
style={{ backgroundColor: (colors as string[])[index] || defaultColor }}
|
|
||||||
/>
|
|
||||||
<small className="default-chart__pie-chart-legend-item-text flex-fill">{label}</small>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartElementAtEvent = (onClick?: (label: string) => void) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
|
|
||||||
if (!onClick || !chart) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { _index, _chart: { data } } = chart;
|
|
||||||
const { labels } = data;
|
|
||||||
|
|
||||||
onClick(labels?.[_index] as string);
|
|
||||||
};
|
|
||||||
|
|
||||||
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
|
|
||||||
|
|
||||||
const DefaultChart = (
|
|
||||||
{ title, isBarChart = false, stats, max, highlightedStats, highlightedLabel, onClick }: DefaultChartProps,
|
|
||||||
) => {
|
|
||||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
|
||||||
const labels = keys(stats).map(dropLabelIfHidden);
|
|
||||||
const data = values(
|
|
||||||
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
|
|
||||||
if (acc[highlightedKey]) {
|
|
||||||
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, { ...stats }),
|
|
||||||
);
|
|
||||||
const highlightedData = statsAreDefined(highlightedStats) ? fillTheGaps(highlightedStats, labels) : undefined;
|
|
||||||
const [ chartRef, setChartRef ] = useState<HorizontalBar | Doughnut | undefined>();
|
|
||||||
|
|
||||||
const options: ChartOptions = {
|
|
||||||
legend: { display: false },
|
|
||||||
legendCallback: !isBarChart && renderPieChartLegend as any,
|
|
||||||
scales: !isBarChart ? undefined : {
|
|
||||||
xAxes: [
|
|
||||||
{
|
|
||||||
ticks: {
|
|
||||||
beginAtZero: true,
|
|
||||||
precision: 0,
|
|
||||||
callback: prettify,
|
|
||||||
max,
|
|
||||||
},
|
|
||||||
stacked: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
yAxes: [{ stacked: true }],
|
|
||||||
},
|
|
||||||
tooltips: {
|
|
||||||
intersect: !isBarChart,
|
|
||||||
// Do not show tooltip on items with empty label when in a bar chart
|
|
||||||
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
|
|
||||||
callbacks: {
|
|
||||||
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onHover: !isBarChart ? undefined : (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
|
||||||
};
|
|
||||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
|
|
||||||
const height = determineHeight(isBarChart, labels);
|
|
||||||
|
|
||||||
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
|
|
||||||
return (
|
|
||||||
<div className="row">
|
|
||||||
<div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}>
|
|
||||||
<Component
|
|
||||||
ref={(element) => setChartRef(element ?? undefined)}
|
|
||||||
key={height}
|
|
||||||
data={graphData}
|
|
||||||
options={options}
|
|
||||||
height={height}
|
|
||||||
getElementAtEvent={chartElementAtEvent(onClick)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!isBarChart && (
|
|
||||||
<div className="col-sm-12 col-md-5">
|
|
||||||
{chartRef?.chartInstance.generateLegend()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DefaultChart;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import DefaultChart, { DefaultChartProps } from './DefaultChart';
|
|
||||||
import './GraphCard.scss';
|
|
||||||
|
|
||||||
interface GraphCardProps extends DefaultChartProps {
|
|
||||||
footer?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GraphCard = ({ title, footer, ...rest }: GraphCardProps) => (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<DefaultChart title={title} {...rest} />
|
|
||||||
</CardBody>
|
|
||||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default GraphCard;
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
|
import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
|
||||||
import { Visit, VisitsLoadFailedAction } from '../types';
|
import { Visit } from '../types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 5000;
|
const ITEMS_PER_PAGE = 5000;
|
||||||
const PARALLEL_REQUESTS_COUNT = 4;
|
const PARALLEL_REQUESTS_COUNT = 4;
|
||||||
@@ -72,6 +73,6 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
|
|||||||
|
|
||||||
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<VisitsLoadFailedAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import {
|
import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
OrphanVisit,
|
|
||||||
OrphanVisitType,
|
|
||||||
Visit,
|
|
||||||
VisitsInfo,
|
|
||||||
VisitsLoadFailedAction,
|
|
||||||
VisitsLoadProgressChangedAction,
|
|
||||||
} from '../types';
|
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, 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 { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { isOrphanVisit } from '../types/helpers';
|
import { isOrphanVisit } from '../types/helpers';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
@@ -31,7 +25,7 @@ export interface OrphanVisitsAction extends Action<string> {
|
|||||||
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& VisitsLoadFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState: VisitsInfo = {
|
const initialState: VisitsInfo = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
import { shortUrlMatches } from '../../short-urls/helpers';
|
||||||
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
|
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
import { ShortUrlIdentifier } from '../../short-urls/data';
|
import { ShortUrlIdentifier } from '../../short-urls/data';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, 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 { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
|
|||||||
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& VisitsLoadFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState: ShortUrlVisits = {
|
const initialState: ShortUrlVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
|
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, 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 { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export interface TagVisitsAction extends Action<string> {
|
|||||||
type TagsVisitsCombinedAction = TagVisitsAction
|
type TagsVisitsCombinedAction = TagVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& VisitsLoadFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState: TagVisits = {
|
const initialState: TagVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user