Compare commits

...

92 Commits

Author SHA1 Message Date
Alejandro Celaya
785806b7a1 Merge pull request #495 from shlinkio/develop
Release 3.3.0
2021-09-25 11:59:18 +02:00
Alejandro Celaya
15b7fd5c93 Merge pull request #494 from acelaya-forks/feature/remove-old-shlink
Feature/remove old shlink
2021-09-25 11:54:26 +02:00
Alejandro Celaya
9b32bd2817 Updated changelog 2021-09-25 11:48:25 +02:00
Alejandro Celaya
8b5b035568 Removed rest of version checks for versions older than 2.4 2021-09-25 11:47:18 +02:00
Alejandro Celaya
f7cc90bb77 Removed some version checks for versions older than 2.4.0 2021-09-25 11:40:16 +02:00
Alejandro Celaya
7b0cda7191 Merge pull request #493 from acelaya-forks/feature/tags-list
Feature/tags list
2021-09-25 11:03:13 +02:00
Alejandro Celaya
9791486341 Created TagsTableRow test 2021-09-25 10:57:42 +02:00
Alejandro Celaya
40ef51a348 Created TagsTable test 2021-09-25 10:37:13 +02:00
Alejandro Celaya
a90287ed02 Updated changelog 2021-09-25 10:07:42 +02:00
Alejandro Celaya
12f6a132bd Added pagination to tags table 2021-09-25 09:34:38 +02:00
Alejandro Celaya
1da7119c5c Added new setting to determine default display mode for tags 2021-09-25 08:20:56 +02:00
Alejandro Celaya
01f6f11ee2 Created new tags components 2021-09-24 20:25:28 +02:00
Alejandro Celaya
57d4db5daa Created TagsModeDropdown component 2021-09-24 20:21:02 +02:00
Alejandro Celaya
c7559e78a2 Created DropdownBtnMenu test 2021-09-24 20:04:16 +02:00
Alejandro Celaya
2f76c5381f Added some tests for new tags components 2021-09-24 19:55:26 +02:00
Alejandro Celaya
304a7431ad Created new component to handle buttons displaying a dropdown menu 2021-09-24 19:10:03 +02:00
Alejandro Celaya
691dabcfbc Merge pull request #489 from acelaya-forks/feature/coverage-80
Feature/coverage 80
2021-09-20 22:13:18 +02:00
Alejandro Celaya
2dd35dcd44 Fixed import statement order 2021-09-20 22:05:24 +02:00
Alejandro Celaya
44930b8c5f Replaced usages of test with it, and updated changelog 2021-09-20 22:00:34 +02:00
Alejandro Celaya
310913b222 Adjusted required code coverage to current values 2021-09-20 21:52:46 +02:00
Alejandro Celaya
b877aa8e5b Improved branching coverage in some parts 2021-09-20 21:51:51 +02:00
Alejandro Celaya
27e3d65143 Created PaginationDropdown test 2021-09-20 21:31:14 +02:00
Alejandro Celaya
b462169e1e Created missing tests for settings components 2021-09-20 21:23:39 +02:00
Alejandro Celaya
dc2f30c73b Recovered test 2021-09-20 20:52:57 +02:00
Alejandro Celaya
8df1ba4671 Added toApiParams test 2021-09-20 20:48:52 +02:00
Alejandro Celaya
56a3dbd07f Created EditTagModal test 2021-09-20 20:32:54 +02:00
Alejandro Celaya
856ee6d65c Created MainHeader test 2021-09-19 11:17:56 +02:00
Alejandro Celaya
9518a5e442 Created ImageDownloader test 2021-09-19 10:57:36 +02:00
Alejandro Celaya
3a8c7a7bf4 Added required code coverage for jest 2021-09-19 10:31:53 +02:00
Alejandro Celaya
7fb0658349 Merge pull request #488 from acelaya-forks/feature/split-charts
Feature/split charts
2021-09-18 19:18:34 +02:00
Alejandro Celaya
6d79851d18 Removed last reference to graph instead of chart 2021-09-18 19:09:31 +02:00
Alejandro Celaya
f89e4244ea Moved LineChartCard with the rest of the charts 2021-09-18 19:07:50 +02:00
Alejandro Celaya
3c23016028 Refactored components used to render charts for visits 2021-09-18 19:05:28 +02:00
Alejandro Celaya
27c4bd792b Merge pull request #487 from acelaya-forks/feature/chartjs-3
Feature/chartjs 3
2021-09-18 13:26:41 +02:00
Alejandro Celaya
1b158b3df4 Fixed links to issues defined as PRs in changelog 2021-09-18 13:22:11 +02:00
Alejandro Celaya
19f0dc2920 Updated changelog 2021-09-18 13:18:48 +02:00
Alejandro Celaya
a15917b1ae Fixed tests 2021-09-18 13:17:04 +02:00
Alejandro Celaya
7e5397dd38 Created PieChartLegend test 2021-09-18 12:59:54 +02:00
Alejandro Celaya
382d7b1c9f Improved comment 2021-09-18 12:34:14 +02:00
Alejandro Celaya
58ee123cef Memoized DefaultChart to make sure it does not change unless its props also change 2021-09-18 12:29:15 +02:00
Alejandro Celaya
039a56f410 Fixed tooltips in bar charts 2021-09-18 12:07:05 +02:00
Alejandro Celaya
6780aa623b Merged develop 2021-09-12 12:28:01 +02:00
Alejandro Celaya
7752140c9d Fixed merge conflicts 2021-09-12 10:33:10 +02:00
Alejandro Celaya
f54460e8f8 First attempt to fix click event on charts 2021-09-05 16:51:18 +02:00
Alejandro Celaya
036c8aafcb Extracted PieChartLegend to its own component 2021-09-05 16:51:18 +02:00
Alejandro Celaya
d55160e8f6 Recovered function to render pie chart labels 2021-09-05 16:51:18 +02:00
Alejandro Celaya
0572bc2854 First iteration to migrate to Chart.js 3. Making it compile 2021-09-05 16:51:18 +02:00
Alejandro Celaya
aceb2350cf Merge pull request #479 from acelaya-forks/feature/tag-special-chars
Fixed tags including special chars being broken when used in URLs
2021-09-01 10:59:45 +02:00
Alejandro Celaya
923575b38b Fixed tags including special chars being broken when used in URLs 2021-09-01 10:54:33 +02:00
Alejandro Celaya
f41a8473f8 Fixed path on preview env 2021-08-29 11:31:33 +02:00
Alejandro Celaya
b94cdb2680 Fixed action version 2021-08-29 10:45:30 +02:00
Alejandro Celaya
0cdae72ebd Migrated to external deploy-preview action 2021-08-29 10:33:55 +02:00
Alejandro Celaya
75931edc33 Merge pull request #473 from acelaya-forks/feature/manage-domains
Feature/manage domains
2021-08-24 20:31:38 +02:00
Alejandro Celaya
d1fcd10c04 Fixed TS errors in tests 2021-08-24 20:26:57 +02:00
Alejandro Celaya
06f4cff97e Fixed missing initial values when editing one domain redirects 2021-08-24 20:24:34 +02:00
Alejandro Celaya
0804322a9f Updated changelog 2021-08-24 20:15:05 +02:00
Alejandro Celaya
53ba14e6f6 Created ManageDomains test 2021-08-24 20:13:54 +02:00
Alejandro Celaya
ead5f2033b Created DomainRow test 2021-08-24 19:53:28 +02:00
Alejandro Celaya
74ac122787 Created EditDomainRedirectsModal test 2021-08-23 19:12:41 +02:00
Alejandro Celaya
13785c7beb Removed styles from one section that ended up in generic component 2021-08-23 18:31:40 +02:00
Alejandro Celaya
9887cae4fd Added InfoTooltip test 2021-08-23 18:26:15 +02:00
Alejandro Celaya
410d372755 Extracted InfoTooltip to its own component 2021-08-22 11:05:07 +02:00
Alejandro Celaya
e7a969a78d Merge branch 'feature/manage-domains' of github.com:acelaya/shlink-web-client-react into feature/manage-domains 2021-08-22 10:47:00 +02:00
Alejandro Celaya
b1d6f58619 Added responsiveness to manage domains table 2021-08-22 10:46:47 +02:00
Alejandro Celaya
f49b74229c Enhanced tooltip 2021-08-22 09:34:56 +02:00
Alejandro Celaya
d88f822125 Extended ShlinkApiClient test covering editDomainRedirects 2021-08-22 09:11:14 +02:00
Alejandro Celaya
dce1cefd49 Created domainRedirects reducer test 2021-08-22 09:06:18 +02:00
Alejandro Celaya
8e71b2e2b1 Improved domainsList reducer test 2021-08-22 09:00:58 +02:00
Alejandro Celaya
69cb3bd619 Implemented logic to edit domain redirects 2021-08-21 17:53:06 +02:00
Alejandro Celaya
bf29158a8a Added missing alignment 2021-08-20 17:31:42 +02:00
Alejandro Celaya
a28a4846bc Created base structure to manage domains 2021-08-20 17:30:07 +02:00
Alejandro Celaya
5eee86003d Merge pull request #471 from acelaya-forks/feature/qr-code-error-correction
Feature/qr code error correction
2021-08-16 17:48:46 +02:00
Alejandro Celaya
37a3a2022b Added missing props on qrCodes test 2021-08-16 17:44:11 +02:00
Alejandro Celaya
c6be8bd96f Created tests for new QR code dropdowns 2021-08-16 17:38:25 +02:00
Alejandro Celaya
5166340779 Extracted some QR code modal components to external components 2021-08-16 17:26:54 +02:00
Alejandro Celaya
520e52595f Updated changelog 2021-08-16 17:14:57 +02:00
Alejandro Celaya
461c0e0bc9 Added new component for QR codes error correction when consuming Shlink 2.8 2021-08-16 17:13:31 +02:00
Alejandro Celaya
0ecb771b23 Created lint:fix global command 2021-08-16 13:21:53 +02:00
Alejandro Celaya
c89e2b5d25 Merge pull request #470 from acelaya-forks/feature/download-qr-code
Feature/download qr code
2021-08-16 13:20:00 +02:00
Alejandro Celaya
aa8f2a0cbc Updated changelog 2021-08-16 13:15:16 +02:00
Alejandro Celaya
eb90aa2274 Added support to download QR codes to the QR code modal 2021-08-16 13:13:41 +02:00
Alejandro Celaya
2b5420a429 Merge pull request #468 from acelaya-forks/feature/tags-global-search
Feature/tags global search
2021-08-15 18:27:16 +02:00
Alejandro Celaya
3484e74559 Fixed coding styles 2021-08-15 18:21:36 +02:00
Alejandro Celaya
edd536cc1e Updated changelog 2021-08-15 18:17:05 +02:00
Alejandro Celaya
322396a366 Allowed to dynamically determine how short URL suggestions are calculated 2021-08-15 18:13:13 +02:00
Alejandro Celaya
9f02bc6496 Added new settings to determine how to search on tags during short URL creation, and how many suggestions to display 2021-08-15 10:58:26 +02:00
Alejandro Celaya
590393dcfd Merge pull request #467 from acelaya-forks/feature/comma-separated-tags
Feature/comma separated tags
2021-08-15 09:54:57 +02:00
Alejandro Celaya
8029823271 Updated changelog 2021-08-15 09:50:27 +02:00
Alejandro Celaya
4417a17d5c Improved TagsSelector component test, covering different logic while adding tags 2021-08-15 09:49:01 +02:00
Alejandro Celaya
b8a7dccf92 Ensured TagsSelector does not allow duplicated tags, and allows adding multiple coma-separated tags at once 2021-08-15 09:45:14 +02:00
Alejandro Celaya
cbe5f98aa3 Merge pull request #466 from acelaya-forks/feature/tags-title
Added dynamic title on hover for tags with a very long title
2021-08-14 19:45:29 +02:00
Alejandro Celaya
6c2f5b99ac Added dynamic title on hover for tags with a very long title 2021-08-14 19:40:53 +02:00
155 changed files with 3239 additions and 1190 deletions

View File

@@ -14,5 +14,8 @@
"process": true, "process": true,
"setImmediate": true "setImmediate": true
}, },
"ignorePatterns": ["src/service*.ts"] "ignorePatterns": ["src/service*.ts"],
"rules": {
"complexity": "off"
}
} }

View File

@@ -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 }}/

View File

@@ -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

View File

@@ -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
View File

@@ -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=="
} }
} }
}, },

View File

@@ -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",

View File

@@ -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
View File

@@ -0,0 +1,6 @@
import { Action } from 'redux';
import { ProblemDetailsError } from './index';
export interface ApiErrorAction extends Action<string> {
errorData?: ProblemDetailsError;
}

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
@import './utils/base'; @import '../utils/base';
.app-container { .app-container {
height: 100%; height: 100%;

View File

@@ -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 {

View File

@@ -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) => {

View File

@@ -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>
); );

View File

@@ -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} />&nbsp; Settings <FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink> </NavLink>
</NavItem> </NavItem>

View File

@@ -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>}
/> />

View 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);
}
}

View File

@@ -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
View 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>
);
};

View 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()}
</>
);
};

View 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>
);
};

View 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) });
}
};

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}`;
} }

View File

@@ -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>

View File

@@ -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 ]);

View File

@@ -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 : '';

View File

@@ -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>

View File

@@ -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';

View File

@@ -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">

View File

@@ -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>

View File

@@ -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> );
); };

View File

@@ -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>
); );

View File

@@ -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 {

View File

@@ -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

View File

@@ -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">

View File

@@ -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>
)}
</> </>
)} )}

View File

@@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
</Card> </Card>
</> </>
); );
}, () => [ Topics.visits() ]); }, () => [ Topics.visits ]);
export default ShortUrlsList; export default ShortUrlsList;

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp; <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>
); );
}; };

View File

@@ -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>
);

View 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>
);

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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) });
} }
}; };

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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
View 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>
);
};

View File

@@ -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;

View 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
View 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
View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { TagsList as TagsListState } from '../reducers/tagsList';
import { SelectedServer } from '../../servers/data';
export interface TagsListChildrenProps {
tagsList: TagsListState;
selectedServer: SelectedServer;
}

View File

@@ -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>

View File

@@ -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(',') ]) ],
)}
/> />
); );
}; };

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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) });
} }
}; };

View File

@@ -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' ],
)); ));

View File

@@ -0,0 +1,3 @@
.dropdown-btn-menu__dropdown-toggle:after {
display: none !important;
}

View 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">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right={right}>{children}</DropdownMenu>
</ButtonDropdown>
);

View File

@@ -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
View 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>
</>
);
};

View File

@@ -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)}
/> />

View File

@@ -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);

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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 ];
};

View File

@@ -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;

View File

@@ -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}`}`;

View 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;
}
}
}

View File

@@ -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)}`;

View File

@@ -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 ]);

View File

@@ -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;

View File

@@ -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')}

View File

@@ -1,4 +1,4 @@
.graph-card__footer--sticky { .chart-card__footer--sticky {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
} }

View 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>
);

View 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>
);
});

View 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>
);

View File

@@ -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;

View 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>
);
};

View 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')}
</>
);
};

View File

@@ -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>
); );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) });
} }
}; };

View File

@@ -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: [],

View File

@@ -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: [],

View File

@@ -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