Compare commits

...

185 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
8bfd38d861 Merge pull request #485 from shlinkio/hotfix/v3.2.1
Release v3.2.1
2021-09-12 10:30:10 +02:00
Alejandro Celaya
27b6676edc Merge pull request #484 from acelaya-forks/feature/wrong-end-dates
ClFeature/wrong end dates
2021-09-12 10:22:30 +02:00
Alejandro Celaya
66c91722fc Updated changelog 2021-09-12 10:17:24 +02:00
Alejandro Celaya
178f15b7d3 Ensured end dates are set at the end of the date when filtering visits 2021-09-12 10:16:05 +02:00
Alejandro Celaya
0e47f9b502 Merge pull request #483 from acelaya-forks/feature/import-servers-win
Feature/import servers win
2021-09-12 10:04:33 +02:00
Alejandro Celaya
d2ad1cd54b Updated changelog 2021-09-12 09:58:24 +02:00
Alejandro Celaya
91e003153b Updated logic to import servers, to not check the file type 2021-09-12 09:56:53 +02:00
Alejandro Celaya
c6cca9c91f Fixed indentation 2021-09-12 09:56:29 +02:00
Alejandro Celaya
7330fd85ff Updated function signaure 2021-09-12 09:34:51 +02:00
Alejandro Celaya
b61d863356 Fixed merge conflicts 2021-09-12 09:03:31 +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
Alejandro Celaya
fa64c950ca Merge pull request #453 from shlinkio/develop
Release 3.2.0
2021-07-12 16:44:20 +02:00
Alejandro Celaya
0e4667e59c Added v3.2.0 to changelog 2021-07-12 16:40:36 +02:00
Alejandro Celaya
56d9dcf562 Merge pull request #452 from acelaya-forks/feature/auto-pwa-restart
Feature/auto pwa restart
2021-07-12 16:39:27 +02:00
Alejandro Celaya
d5e8f81076 Created AppUpdateBanner test 2021-07-12 16:34:58 +02:00
Alejandro Celaya
69905c4b38 Added logic to allow refreshing the PWA without closing the tabs 2021-07-12 16:16:18 +02:00
Alejandro Celaya
08694d7693 Extracted update banner to a separated component 2021-07-12 12:24:04 +02:00
Alejandro Celaya
8045fa8886 Added more improvements to landing page 2021-07-12 12:05:33 +02:00
Alejandro Celaya
0789494a40 Removed deprecated env var for publish release 2021-07-11 22:30:53 +02:00
Alejandro Celaya
34837f2917 Merge pull request #451 from acelaya-forks/feature/improve-landing
Feature/improve landing
2021-07-11 22:29:45 +02:00
Alejandro Celaya
9e8c743d53 Updated changelog 2021-07-11 22:26:11 +02:00
Alejandro Celaya
239cc4ab84 Improved landing page design 2021-07-11 22:25:36 +02:00
Alejandro Celaya
b3e79f4219 Merge pull request #447 from acelaya-forks/feature/visits-filter-reducer
Feature/visits filter reducer
2021-07-02 20:10:36 +02:00
Alejandro Celaya
7c11a6d1ab Allowed to deselect orphan visits type 2021-07-02 20:05:51 +02:00
Alejandro Celaya
635ee6c5eb Updated changelog 2021-07-02 19:57:25 +02:00
Alejandro Celaya
f79bd39de7 Moved logic to filter visits to reducers 2021-06-30 03:23:45 +02:00
Alejandro Celaya
5c6979122d Extracted VisitsFilter type from component for general usage 2021-06-30 02:36:13 +02:00
Alejandro Celaya
402efac12e Merge pull request #446 from acelaya-forks/feature/tags-input
Feature/tags input
2021-06-26 17:50:12 +02:00
Alejandro Celaya
770ba624c2 Created TagsSelector test 2021-06-26 17:44:26 +02:00
Alejandro Celaya
d4236b914d Updated changelog 2021-06-26 17:07:59 +02:00
Alejandro Celaya
2cc92b5b41 Ensured tags are added onBlur 2021-06-26 17:06:39 +02:00
Alejandro Celaya
f0598ba47f Changed min query length for tags input to 1 2021-06-26 10:44:17 +02:00
Alejandro Celaya
66c5c7ebf1 Replaced tags component by one which is better maintained 2021-06-26 10:17:07 +02:00
Alejandro Celaya
741bc21a55 Merge pull request #445 from acelaya-forks/feature/moment-js-migration
Feature/moment js migration
2021-06-25 20:09:12 +02:00
Alejandro Celaya
fb1ced5e3f Created test for Time component 2021-06-25 20:05:06 +02:00
Alejandro Celaya
3999d14bab Created abstraction function to parse dates 2021-06-25 19:52:50 +02:00
Alejandro Celaya
99c77622cd Updated changelog 2021-06-25 19:41:25 +02:00
Alejandro Celaya
bc5c25deb0 Fixed issue due to immutability 2021-06-25 19:33:18 +02:00
Alejandro Celaya
0275908f69 Removed last references to moment.js from the project 2021-06-25 19:15:19 +02:00
Alejandro Celaya
4be1a295d8 Replaced most of the usages of moment with date-fns 2021-06-24 20:13:06 +02:00
Alejandro Celaya
ee65c0c050 Merge pull request #444 from acelaya-forks/feature/crawlable-option
Feature/crawlable option
2021-06-23 20:03:37 +02:00
Alejandro Celaya
d718329b52 Updated changelog 2021-06-23 19:59:47 +02:00
Alejandro Celaya
55716a8f7f Created ShortUrlFormCheckboxGroup test 2021-06-23 19:59:06 +02:00
Alejandro Celaya
5ef719c592 Added support to set crawlable short URLs during creation and edition 2021-06-23 19:52:23 +02:00
Alejandro Celaya
3a57416525 Merge pull request #443 from acelaya-forks/feature/visits-filtering
Feature/visits filtering
2021-06-22 21:17:35 +02:00
Alejandro Celaya
5bd57e71fd Improved DropdownBtn test 2021-06-22 21:12:06 +02:00
Alejandro Celaya
c4ed838510 Updated changelog 2021-06-22 21:06:29 +02:00
Alejandro Celaya
affe2309b0 Ensured filter for bots does not show for Shlink older than 2.7.0 2021-06-22 21:03:47 +02:00
Alejandro Celaya
638ce89780 Improved dropdown to filter visits, adding support to filter out bots 2021-06-22 20:46:28 +02:00
Alejandro Celaya
a0ab9533cb Merge pull request #441 from acelaya-forks/feature/bots-support
Feature/bots support
2021-06-13 11:58:58 +02:00
Alejandro Celaya
7b80948eea Fixed TS errors in tests 2021-06-13 11:54:51 +02:00
Alejandro Celaya
1cf96c7212 Improved VisitsTable test 2021-06-13 11:49:53 +02:00
Alejandro Celaya
151175dc70 Updated changelog 2021-06-13 11:41:41 +02:00
Alejandro Celaya
a30376344e Added tests covering visits table with potential bots 2021-06-13 11:38:13 +02:00
Alejandro Celaya
db0c43dcdd Added column to display if a visit is a potential bot in the visits table 2021-06-13 11:07:32 +02:00
Alejandro Celaya
a3550f8e52 Updated docker images 2021-06-13 09:55:07 +02:00
Alejandro Celaya
3a3babadeb Renamed script 2021-06-13 09:51:10 +02:00
Alejandro Celaya
e22ad2c822 Merge pull request #439 from acelaya-forks/feature/fix-horizontal-scroll
Fixed horizontal scroll
2021-06-13 07:59:19 +02:00
Alejandro Celaya
342dda3ec9 Fixed horizontal scroll 2021-06-13 07:52:53 +02:00
Alejandro Celaya
b7af07c043 Fixed docker build script so that it can work with develop branch 2021-06-06 19:27:43 +02:00
Alejandro Celaya
6b338275d3 Updated branch where the docker image builds unstable versions 2021-06-06 19:24:57 +02:00
Alejandro Celaya
a72d3b2720 Updated changelog 2021-06-06 19:14:18 +02:00
Alejandro Celaya
18042dba6e Merge branch 'main' into develop 2021-06-06 19:13:29 +02:00
Alejandro Celaya
6e09d1372f Merge pull request #436 from acelaya-forks/feature/recover-pwa
Feature/recover pwa
2021-06-06 19:11:37 +02:00
Alejandro Celaya
ce02d29ca3 Ensure review environment does not contain a service worker 2021-06-06 19:06:24 +02:00
Alejandro Celaya
e193c700d6 Fixed TS error in App test 2021-06-06 18:58:05 +02:00
Alejandro Celaya
bfeb282aa9 Added appUpdates reducer test 2021-06-06 18:49:38 +02:00
Alejandro Celaya
5caa648112 Added banner to be displayed when the service worker has updated the app in the background 2021-06-06 18:41:10 +02:00
Alejandro Celaya
4546b74b6f Added missing webpack config that generates service worker 2021-06-06 12:54:32 +02:00
Alejandro Celaya
2fb5507803 Added service worker back to the project to recover PWA capabilities 2021-06-06 12:27:02 +02:00
Alejandro Celaya
93329c5a12 erge branch 'develop' of github.com:shlinkio/shlink-web-client into develop 2021-05-30 17:51:16 +02:00
Alejandro Celaya
5a91b668dc Updated changelog 2021-05-30 17:50:54 +02:00
Alejandro Celaya
66aac4771c Merge pull request #434 from matiasgarciaisaia/patch-1
Update server.json alternative Docker configs in README.md
2021-05-29 18:27:27 +02:00
Matías García Isaía
ce04b8eb58 Update server.json alternative Docker configs in README.md
See #432 & #433
2021-05-29 11:11:24 -03:00
Alejandro Celaya
e0c20c704e Merge pull request #432 from matiasgarciaisaia/feature/conf-volume
Support servers.json in a conf.d directory
2021-05-29 11:58:41 +02:00
Alejandro Celaya
d5fadc56af Removed new empty line added by mistake 2021-05-29 11:54:08 +02:00
Alejandro Celaya
bbc3342c00 Moved servers.json config on nginx above another less restrictive but conflicting rule 2021-05-29 11:53:06 +02:00
Matias Garcia Isaia
76ebbd318a Support servers.json in a conf.d directory
In Cattle (and maybe other Docker environments) you can't mount specific files, but
have to mount a whole volume as a directory.

We now allow the servers.json to be looked for inside a specific folder to support
that use case.
2021-05-29 11:41:32 +02:00
Alejandro Celaya
24801b068b Updated changelog 2021-05-29 11:40:14 +02:00
Alejandro Celaya
4c21ad0a89 Merge pull request #433 from matiasgarciaisaia/feature/server-from-env
Single-server servers.json from environment variables in Docker image
2021-05-29 11:35:27 +02:00
Alejandro Celaya
f626f9b046 Renamed env vars 2021-05-29 11:30:35 +02:00
Matias Garcia Isaia
ccffa0fe12 Allow Docker image to generate servers.json from environment
In the Docker image, generate the servers.json with a single server
by reading environment variables.
2021-05-28 22:01:39 -03:00
Alejandro Celaya
d5530b4614 Merge pull request #429 from acelaya-forks/feature/stryker5
Updated to stryker 5
2021-05-15 12:08:26 +02:00
Alejandro Celaya
7c327099bb Updated changelog 2021-05-15 12:04:25 +02:00
Alejandro Celaya
577d7e79da Updated to stryker 5 2021-05-15 12:02:43 +02:00
Alejandro Celaya
31736fad1e Ensured proper ref is checked out on preview env 2021-05-09 21:09:24 +02:00
Alejandro Celaya
6319a81ddb Fixed event name 2021-05-09 21:02:32 +02:00
Alejandro Celaya
0ca6ff6906 Ensured checkout is done from remote remo 2021-05-09 20:52:15 +02:00
Alejandro Celaya
eb69165781 Changed event for preview deployments to use pull_request_target 2021-05-09 14:39:13 +02:00
Alejandro Celaya
4e3d311bef Changed token used for preview deployment 2021-05-09 14:25:10 +02:00
Alejandro Celaya
54b7aeed20 Added github token to preview env deployment 2021-05-09 14:14:56 +02:00
Alejandro Celaya
2ba8db1fd3 Ensured preview envs are generated on PRs only 2021-05-09 14:03:59 +02:00
Alejandro Celaya
f74270a767 Ensured branch slug is generated before building project on preview env deployment 2021-05-09 13:53:36 +02:00
Alejandro Celaya
9a245fbf13 Created new workflow to generate preview envs 2021-05-09 13:34:39 +02:00
219 changed files with 6005 additions and 2020 deletions

View File

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

29
.github/workflows/deploy-preview.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Deploy preview
on:
pull_request_target: null
jobs:
deploy:
runs-on: ubuntu-20.04
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- name: Build
run: |
npm ci && \
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
rm src/service-worker.ts && \
npm run build
- name: Deploy preview
uses: shlinkio/deploy-preview-action@v1.0.1
with:
folder: build

View File

@@ -3,7 +3,7 @@ name: Build docker image
on:
push:
branches:
- main
- develop
tags:
- 'v*'

View File

@@ -21,7 +21,6 @@ jobs:
uses: docker://antonyurchenko/git-release:latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOW_TAG_PREFIX: "true"
ALLOW_EMPTY_CHANGELOG: "true"
with:
args: |

View File

@@ -4,6 +4,105 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.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
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#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/issues/480) Fixed servers import on Chromium-based browsers when using windows.
* [#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
### Added
* [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars:
* `SHLINK_SERVER_URL`: The URL of the Shlink server to configure by default.
* `SHLINK_SERVER_API_KEY`: The API key of the Shlink server.
* `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.
* [#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/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/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/issues/450) Improved landing page design.
* [#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
* [#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/issues/337) Replaced moment.js with date-fns.
* [#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
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#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
### Added
* *Nothing*
### Changed
* [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#371](https://github.com/shlinkio/shlink-web-client/issues/371) Recovered PWA functionality.
## [3.1.1] - 2021-05-08
### Added
* *Nothing*

View File

@@ -1,12 +1,13 @@
FROM node:14.15-alpine as node
FROM node:14.17-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.19.6-alpine
FROM nginx:1.21-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh
COPY --from=node /shlink-web-client/build /usr/share/nginx/html

View File

@@ -68,6 +68,25 @@ Those servers can be exported and imported in other browsers, but if for some re
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/my-config/:/usr/share/nginx/html/conf.d/ shlinkio/shlink-web-client
If you want to pre-configure a single server, you can provide its config via env vars. When the container starts up, it will build the `servers.json` file dynamically based on them. *(since shlink-web-client 3.2.0)*.
* `SHLINK_SERVER_URL`: The fully qualified URL for the Shlink server.
* `SHLINK_SERVER_API_KEY`: The API key.
* `SHLINK_SERVER_NAME`: The name to be displayed. Defaults to **Shlink** if not provided.
```shell
docker run \
--name shlink-web-client \
-p 8000:80 \
-e SHLINK_SERVER_URL=https://doma.in \
-e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \
shlinkio/shlink-web-client
```
> **Be extremely careful when using this feature.**
>

View File

@@ -20,6 +20,11 @@ server {
add_header Cache-Control "public";
}
# servers.json may be on the root, or in conf.d directory
location = /servers.json {
try_files /servers.json /conf.d/servers.json;
}
# When requesting static paths with extension, try them, and return a 404 if not found
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
try_files $uri $uri/ =404;

View File

@@ -83,6 +83,7 @@ module.exports = {
appNodeModules: resolveApp('node_modules'),
publicUrl: getPublicUrl(resolveApp('package.json')),
servedPath: getServedPath(resolveApp('package.json')),
swSrc: resolveModule(resolveApp, 'src/service-worker'),
};
module.exports.moduleFileExtensions = moduleFileExtensions;

View File

@@ -13,6 +13,7 @@ const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const safePostCssParser = require('postcss-safe-parser');
const ManifestPlugin = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
@@ -32,6 +33,9 @@ const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
// Get the path to the uncompiled service worker (if it exists).
const swSrc = paths.swSrc;
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
@@ -610,6 +614,18 @@ module.exports = (webpackEnv) => {
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the webpack build.
isEnvProduction && fs.existsSync(swSrc) && new WorkboxWebpackPlugin.InjectManifest({
swSrc,
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
exclude: [ /\.map$/, /asset-manifest\.json$/, /LICENSE/ ],
// Bump up the default maximum size (2mb) that's precached,
// to make lazy-loading failure scenarios less likely.
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
}),
// TypeScript type checking
useTypeScript &&
new ForkTsCheckerWebpackPlugin({

View File

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

View File

@@ -1,13 +1,20 @@
module.exports = {
coverageDirectory: '<rootDir>/coverage',
collectCoverageFrom: [
'src/**/*.{js,ts,tsx}',
'!src/registerServiceWorker.js',
'!src/index.ts',
'src/**/*.{ts,tsx}',
'!src/*.{ts,tsx}',
'!src/reducers/index.ts',
'!src/**/provideServices.ts',
'!src/container/*.ts',
],
coverageThreshold: {
global: {
statements: 85,
branches: 75,
functions: 80,
lines: 85,
},
},
resolver: 'jest-pnp-resolver',
setupFiles: [
'react-app-polyfill/jsdom',

1600
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,18 @@
"license": "MIT",
"scripts": {
"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: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:js:fix": "npm run lint:js -- --fix",
"start": "node scripts/start.js",
"serve:build": "serve ./build",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom --colors --verbose",
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
},
"dependencies": {
@@ -29,55 +31,55 @@
"bootstrap": "^4.6.0",
"bottlejs": "^2.0.0",
"bowser": "^2.11.0",
"chart.js": "^2.9.4",
"chart.js": "^3.5.1",
"classnames": "^2.2.6",
"compare-versions": "^3.6.0",
"csvjson": "^5.1.0",
"date-fns": "^2.22.1",
"event-source-polyfill": "^1.0.22",
"leaflet": "^1.7.1",
"moment": "^2.29.1",
"promise": "^8.1.0",
"qs": "^6.9.6",
"ramda": "^0.27.1",
"react": "^17.0.1",
"react-autosuggest": "^10.1.0",
"react-chartjs-2": "^2.11.1",
"react-chartjs-2": "^3.0.4",
"react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "^3.6.0",
"react-dom": "^17.0.1",
"react-external-link": "^1.2.0",
"react-leaflet": "^3.1.0",
"react-moment": "^1.0.0",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-swipeable": "^6.0.1",
"react-tagsinput": "^3.19.0",
"react-tag-autocomplete": "^6.1.0",
"reactstrap": "^8.9.0",
"redux": "^4.0.5",
"redux-localstorage-simple": "^2.4.0",
"redux-thunk": "^2.3.0",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"workbox-core": "^6.1.5",
"workbox-expiration": "^6.1.5",
"workbox-precaching": "^6.1.5",
"workbox-routing": "^6.1.5",
"workbox-strategies": "^6.1.5"
},
"devDependencies": {
"@babel/core": "^7.13.8",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^4.4.1",
"@stryker-mutator/jest-runner": "^4.4.1",
"@stryker-mutator/typescript-checker": "^4.4.1",
"@stryker-mutator/core": "^5.0.0",
"@stryker-mutator/jest-runner": "^5.0.0",
"@stryker-mutator/typescript-checker": "^5.0.0",
"@svgr/webpack": "^5.5.0",
"@types/chart.js": "^2.9.31",
"@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.8",
"@types/jest": "^26.0.20",
"@types/leaflet": "^1.5.23",
"@types/moment": "^2.13.0",
"@types/qs": "^6.9.5",
"@types/ramda": "^0.27.38",
"@types/react": "^17.0.2",
"@types/react-autosuggest": "^10.1.2",
"@types/react-color": "^3.0.4",
"@types/react-copy-to-clipboard": "^5.0.0",
"@types/react-datepicker": "^3.1.5",
@@ -85,7 +87,7 @@
"@types/react-leaflet": "^2.5.2",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"@types/react-tagsinput": "^3.19.7",
"@types/react-tag-autocomplete": "^6.1.0",
"@types/uuid": "^8.3.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
"adm-zip": "^0.4.16",
@@ -129,7 +131,7 @@
"resolve": "^1.19.0",
"sass": "^1.29.0",
"sass-loader": "^10.1.0",
"serve": "^11.3.2",
"serve": "^12.0.0",
"stryker-cli": "^1.0.0",
"style-loader": "^2.0.0",
"stylelint": "^13.7.2",
@@ -146,7 +148,8 @@
"webpack": "^4.44.2",
"webpack-dev-server": "^3.11.0",
"webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.5.0"
"whatwg-fetch": "^3.5.0",
"workbox-webpack-plugin": "^6.1.5"
},
"babel": {
"presets": [

View File

@@ -5,12 +5,12 @@ set -ex
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
DOCKER_IMAGE="shlinkio/shlink-web-client"
if [[ "$GITHUB_REF" == *"main"* ]]; then
if [[ "$GITHUB_REF" == *"develop"* ]]; then
docker buildx build --push \
--platform ${PLATFORMS} \
-t ${DOCKER_IMAGE}:latest .
# If ref is not main, then this is a tag. Build that docker tag and also "stable"
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
else
VERSION=${GITHUB_REF#refs/tags/v}
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"

View File

@@ -0,0 +1,16 @@
#!/bin/sh
set -e
ME=$(basename $0)
setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
local name="${SHLINK_SERVER_NAME:-Shlink}"
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
}
setup_single_shlink_server
exit 0

13
scripts/set-homepage.js Normal file
View File

@@ -0,0 +1,13 @@
const argv = process.argv.slice(2);
const [ homepage ] = argv;
if (!homepage) {
throw new Error('Homepage has to be provided as the first arg for this script');
}
const packageJsonPath = `${__dirname}/../package.json`;
const packageJson = require(packageJsonPath);
const fs = require('fs');
packageJson.homepage = homepage;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));

View File

@@ -1,4 +1,3 @@
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
import { AxiosInstance, AxiosResponse, Method } from 'axios';
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
@@ -16,7 +15,10 @@ import {
ShlinkDomain,
ShlinkDomainsResponse,
ShlinkVisitsOverview,
ShlinkEditDomainRedirects,
ShlinkDomainRedirects,
} from '../types';
import { stringifyQuery } from '../../utils/helpers/query';
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = reject(isNil);
@@ -108,6 +110,11 @@ export default class ShlinkApiClient {
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
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>> => {
try {
return await this.axios({
@@ -116,7 +123,7 @@ export default class ShlinkApiClient {
headers: { 'X-Api-Key': this.apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
paramsSerializer: stringifyQuery,
});
} catch (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

@@ -55,6 +55,7 @@ export interface ShlinkVisitsParams {
itemsPerPage?: number;
startDate?: string;
endDate?: string;
excludeBots?: boolean;
}
export interface ShlinkShortUrlData extends ShortUrlMeta {
@@ -64,9 +65,20 @@ export interface ShlinkShortUrlData extends ShortUrlMeta {
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 {
domain: string;
isDefault: boolean;
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
}
export interface ShlinkDomainsResponse {

View File

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

View File

@@ -1,15 +1,19 @@
import { useEffect, FC } from 'react';
import { Route, Switch } from 'react-router-dom';
import NotFound from './common/NotFound';
import { ServersMap } from './servers/data';
import { Settings } from './settings/reducers/settings';
import { changeThemeInMarkup } from './utils/theme';
import NotFound from '../common/NotFound';
import { ServersMap } from '../servers/data';
import { Settings } from '../settings/reducers/settings';
import { changeThemeInMarkup } from '../utils/theme';
import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
interface AppProps {
fetchServers: Function;
fetchServers: () => void;
servers: ServersMap;
settings: Settings;
resetAppUpdate: () => void;
appUpdated: boolean;
}
const App = (
@@ -20,7 +24,7 @@ const App = (
EditServer: FC,
Settings: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings }: AppProps) => {
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
useEffect(() => {
// On first load, try to fetch the remote servers if the list is empty
if (Object.keys(servers).length === 0) {
@@ -50,6 +54,8 @@ const App = (
<ShlinkVersionsContainer />
</div>
</div>
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
</div>
);
};

View File

@@ -0,0 +1,18 @@
import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
/* eslint-enable padding-line-between-statements */
const initialState = false;
export default buildReducer<boolean, Action<string>>({
[APP_UPDATE_AVAILABLE]: () => true,
[RESET_APP_UPDATE]: () => false,
}, initialState);
export const appUpdateAvailable = buildActionCreator(APP_UPDATE_AVAILABLE);
export const resetAppUpdate = buildActionCreator(RESET_APP_UPDATE);

View File

@@ -0,0 +1,26 @@
import Bottle from 'bottlejs';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
import App from '../App';
import { ConnectDecorator } from '../../container/types';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory(
'App',
App,
'MainHeader',
'Home',
'MenuLayout',
'CreateServer',
'EditServer',
'Settings',
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
// Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
};
export default provideServices;

View File

@@ -0,0 +1,17 @@
@import '../utils/base';
@import '../utils/mixins/horizontal-align';
.app-update-banner.app-update-banner {
@include horizontal-align();
position: fixed;
top: $headerHeight - 25px;
padding: 0 4rem 0 0;
z-index: 1040;
margin: 0;
color: var(--text-color);
text-align: center;
width: 700px;
max-width: calc(100% - 30px);
box-shadow: 0 0 1rem var(--brand-color);
}

View File

@@ -0,0 +1,34 @@
import { FC, MouseEventHandler } from 'react';
import { Alert, Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { SimpleCard } from '../utils/SimpleCard';
import { useToggle } from '../utils/helpers/hooks';
import './AppUpdateBanner.scss';
interface AppUpdateBannerProps {
isOpen: boolean;
toggle: MouseEventHandler<any>;
forceUpdate: Function;
}
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
const [ isUpdating,, setUpdating ] = useToggle();
const update = () => {
setUpdating();
forceUpdate();
};
return (
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary">
<h4 className="mb-4">This app has just been updated!</h4>
<p className="mb-0">
Restart it to enjoy the new features.
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
{isUpdating && <>Restarting...</>}
</Button>
</p>
</Alert>
);
};

View File

@@ -4,6 +4,7 @@ import {
faTags as tagsIcon,
faPen as editIcon,
faHome as overviewIcon,
faGlobe as domainsIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
@@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom';
import classNames from 'classnames';
import { Location } from 'history';
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';
export interface AsideMenuProps {
selectedServer: ServerWithId;
selectedServer: SelectedServer;
className?: string;
showOnMobile?: boolean;
}
@@ -38,7 +40,8 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, showOnMobile = false }: AsideMenuProps,
) => {
const serverId = selectedServer ? selectedServer.id : '';
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
@@ -49,30 +52,38 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<AsideMenuItem to={buildPath('/overview')}>
<FontAwesomeIcon icon={overviewIcon} />
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span>
</AsideMenuItem>
<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>
</AsideMenuItem>
<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>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/manage-tags')}>
<FontAwesomeIcon icon={tagsIcon} />
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</AsideMenuItem>
{addManageDomainsLink && (
<AsideMenuItem to={buildPath('/manage-domains')}>
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
<span className="aside-menu__item-text">Manage domains</span>
</AsideMenuItem>
)}
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon icon={editIcon} />
<FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer}
/>
{isServerWithId(selectedServer) && (
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer}
/>
)}
</nav>
</aside>
);

View File

@@ -2,10 +2,12 @@ import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom';
import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import ServersListGroup from '../servers/ServersListGroup';
import './Home.scss';
import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
export interface HomeProps {
servers: ServersMap;
@@ -30,12 +32,19 @@ const Home = ({ servers }: HomeProps) => {
</div>
<ServersListGroup embedded servers={serversList}>
{!hasServers && (
<div className="p-4">
<p>This application will help you to manage your Shlink servers.</p>
<p>To start, please, <Link to="/server/create">add your first server</Link>.</p>
<p className="m-0">
You still don&lsquo;t have a Shlink server?
Learn how to <ExternalLink href="https://shlink.io/documentation">get started</ExternalLink>.
<div className="p-4 text-center">
<p className="mb-5">This application will help you manage your Shlink servers.</p>
<p>
<Link to="/server/create" className="btn btn-outline-primary btn-lg mr-2">
<FontAwesomeIcon icon={faPlus} /> <span className="ml-1">Add a server</span>
</Link>
</p>
<p className="mb-0 mt-5">
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="mr-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
</p>
</div>
)}

View File

@@ -31,7 +31,7 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
<Collapse navbar isOpen={isOpen}>
<Nav navbar className="ml-auto">
<NavItem>
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>

View File

@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features';
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data';
import NotFound from './NotFound';
import { AsideMenuProps } from './AsideMenu';
@@ -22,6 +22,7 @@ const MenuLayout = (
ServerError: FC,
Overview: FC,
EditShortUrl: FC,
ManageDomains: FC,
) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
@@ -31,8 +32,8 @@ const MenuLayout = (
return <ServerError />;
}
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
@@ -52,9 +53,10 @@ const MenuLayout = (
<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/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} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/>

View File

@@ -0,0 +1,145 @@
@import '../utils/base';
.react-tags {
position: relative;
padding: 5px 0 0 6px;
border-radius: .3rem;
background-color: var(--input-color);
border: 1px solid var(--input-border-color);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
/* shared font styles */
font-size: 1em;
line-height: 1.2;
/* clicking anywhere will focus the input */
cursor: text;
}
.react-tags.is-focused {
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
}
.react-tags__tag {
font-size: 100%;
}
.react-tags__selected {
display: inline;
vertical-align: 2px;
}
.react-tags__selected-tag {
display: inline-block;
box-sizing: border-box;
margin: 0 6px 6px 0;
padding: 6px 8px;
border: 1px solid var(--input-border-color);
border-radius: .25rem;
background: #f1f1f1;
/* match the font styles */
font-size: inherit;
line-height: inherit;
}
.react-tags__selected-tag:after {
content: '\2715';
color: #aaaaaa;
margin-left: 8px;
}
.react-tags__selected-tag:hover,
.react-tags__selected-tag:focus {
border-color: var(--input-border-color);
}
.react-tags__search {
display: inline-block;
/* match tag layout */
padding: 6px 2px;
margin-bottom: 5px;
/* prevent autoresize overflowing the container */
max-width: 100%;
}
@media screen and (min-width: $smMin) {
.react-tags__search {
/* this will become the offsetParent for suggestions */
position: relative;
}
}
.react-tags__search-input {
font-size: 1.25rem;
line-height: inherit;
color: var(--input-text-color);
background-color: var(--input-color);
/* prevent autoresize overflowing the container */
max-width: 100%;
/* remove styles and layout from this element */
margin: 0 0 0 7px;
padding: 0;
border: 0;
outline: none;
}
.react-tags__search-input::-ms-clear {
display: none;
}
.react-tags__suggestions {
position: absolute;
top: 100%;
left: 0;
width: 100%;
z-index: 10;
}
@media screen and (min-width: $smMin) {
.react-tags__suggestions {
width: 240px;
}
}
.react-tags__suggestions ul {
margin: 4px -1px;
padding: 0;
list-style: none;
background: var(--primary-color);
border: 1px solid var(--border-color);
border-radius: .25rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, .2);
}
.react-tags__suggestions li {
padding: 8px 10px;
}
.react-tags__suggestions li:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
.react-tags__suggestions li mark {
text-decoration: underline;
background: none;
font-weight: 600;
}
.react-tags__suggestions li:hover {
cursor: pointer;
background-color: var(--active-color);
}
.react-tags__suggestions li.is-active {
background-color: var(--active-color);
}
.react-tags__suggestions li.is-disabled {
opacity: .5;
cursor: auto;
}

View File

@@ -1,58 +0,0 @@
@import '../utils/base';
.react-tagsinput {
background-color: var(--input-color);
border: 1px solid var(--input-border-color);
border-radius: .25rem;
overflow: hidden;
min-height: 2.6rem;
padding: .5rem 0 0 1rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.react-tagsinput--focused {
border-color: #80bdff;
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
}
.react-tagsinput-tag {
font-size: 1rem;
background-color: #f1f1f1;
border-radius: 4px;
display: inline-block;
font-weight: 400;
margin: 0 5px 6px 0;
padding: 6px 8px;
line-height: 1;
color: #ffffff;
}
.react-tagsinput-remove {
cursor: pointer;
font-weight: 700;
margin-left: 8px;
}
.react-tagsinput-tag span:before {
content: '\2715';
color: #ffffff;
}
.react-tagsinput-input {
background: transparent;
border: 0;
outline: none;
padding: 1px 0;
width: 100%;
margin-bottom: 6px;
font-size: 1.25rem;
color: var(--input-text-color);
}
.react-tagsinput-input::placeholder {
color: $textPlaceholder;
}
.react-autosuggest__suggestion--highlighted {
background-color: var(--active-color);
}

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 { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ImageDownloader } from './ImageDownloader';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Services
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
// Components
bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
@@ -38,6 +43,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ServerError',
'Overview',
'EditShortUrl',
'ManageDomains',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);

View File

@@ -2,7 +2,6 @@ import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import App from '../App';
import provideApiServices from '../api/services/provideServices';
import provideCommonServices from '../common/services/provideServices';
import provideShortUrlsServices from '../short-urls/services/provideServices';
@@ -13,6 +12,7 @@ import provideUtilsServices from '../utils/services/provideServices';
import provideMercureServices from '../mercure/services/provideServices';
import provideSettingsServices from '../settings/services/provideServices';
import provideDomainsServices from '../domains/services/provideServices';
import provideAppServices from '../app/services/provideServices';
import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
@@ -33,19 +33,7 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
actionServiceNames.reduce(mapActionService, {}),
);
bottle.serviceFactory(
'App',
App,
'MainHeader',
'Home',
'MenuLayout',
'CreateServer',
'EditServer',
'Settings',
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers', 'settings' ], [ 'fetchServers' ]));
provideAppServices(bottle, connect);
provideCommonServices(bottle, connect, withRouter);
provideApiServices(bottle);
provideShortUrlsServices(bottle, connect);

View File

@@ -35,6 +35,7 @@ export interface ShlinkState {
settings: Settings;
domainsList: DomainsList;
visitsOverview: VisitsOverview;
appUpdated: boolean;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

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 { ShlinkDomain } from '../../api/types';
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
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 */
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
/* eslint-enable padding-line-between-statements */
export interface DomainsList {
domains: ShlinkDomain[];
filteredDomains: ShlinkDomain[];
loading: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface ListDomainsAction extends Action<string> {
domains: ShlinkDomain[];
}
interface FilterDomainsAction extends Action<string> {
searchTerm: string;
}
const initialState: DomainsList = {
domains: [],
filteredDomains: [],
loading: 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_ERROR]: () => ({ ...initialState, error: true }),
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
[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);
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
@@ -44,6 +72,8 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
} 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 { ConnectDecorator } from '../../container/types';
import { listDomains } from '../reducers/domainsList';
import { filterDomains, listDomains } from '../reducers/domainsList';
import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect(
[ 'domainsList' ],
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
));
// Actions
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
bottle.serviceFactory('filterDomains', () => filterDomains);
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
};
export default provideServices;

View File

@@ -2,8 +2,10 @@
@import './utils/base';
@import 'node_modules/bootstrap/scss/bootstrap.scss';
@import './common/react-tagsinput.scss';
@import './common/react-tag-autocomplete.scss';
@import './theme/theme';
@import './utils/table/ResponsiveTable';
@import './utils/StickyCardPaginator';
* {
outline: none !important;

View File

@@ -5,6 +5,7 @@ import { homepage } from '../package.json';
import container from './container';
import store from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
import './index.scss';
@@ -12,7 +13,7 @@ import './index.scss';
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
fixLeafletIcons();
const { App, ScrollToTop, ErrorHandler } = container;
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
render(
<Provider store={store}>
@@ -26,3 +27,12 @@ render(
</Provider>,
document.getElementById('root'),
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://cra.link/PWA
registerServiceWorker({
onUpdate() {
store.dispatch(appUpdateAvailable()); // eslint-disable-line @typescript-eslint/no-unsafe-call
},
});

View File

@@ -1,7 +1,7 @@
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

@@ -17,6 +17,7 @@ import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import appUpdatesReducer from '../app/reducers/appUpdates';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
@@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
settings: settingsReducer,
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer,
});

View File

@@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
return (
<>
<span className={className} onClick={showModal}>
{!children && <FontAwesomeIcon icon={deleteIcon} />}
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
<span className={textClassName}>{children ?? 'Remove this server'}</span>
</span>

View File

@@ -55,14 +55,7 @@ export const Overview = (
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body>
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
<CardText tag="h2">
<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>
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
</Card>
</div>
<div className="col-md-6 col-xl-3">
@@ -120,4 +113,4 @@ export const Overview = (
</Card>
</>
);
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
}, () => [ Topics.visits, Topics.orphanVisits ]);

View File

@@ -15,7 +15,7 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
const serversList = values(servers);
const createServerItem = (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span>
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
</DropdownItem>
);

View File

@@ -40,3 +40,5 @@ export const isReachableServer = (server: SelectedServer): server is ReachableSe
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
!!server?.hasOwnProperty('serverNotFound');
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';

View File

@@ -7,7 +7,7 @@ type Ref<T> = RefObject<T> | MutableRefObject<T>;
export interface ImportServersBtnProps {
onImport?: () => void;
onImportError?: () => void;
onImportError?: (error: Error) => void;
}
interface ImportServersBtnConnectProps extends ImportServersBtnProps {

View File

@@ -1,5 +1,5 @@
import { FC, ReactNode, useEffect, useState } from 'react';
import { FormGroupContainer } from '../../utils/FormGroupContainer';
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
import { handleEventPreventingDefault } from '../../utils/utils';
import { ServerData } from '../data';
import { SimpleCard } from '../../utils/SimpleCard';
@@ -11,6 +11,9 @@ interface ServerFormProps {
title?: ReactNode;
}
const FormGroup: FC<FormGroupContainerProps> = (props) =>
<FormGroupContainer {...props} labelClassName="create-server__label" />;
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
@@ -26,9 +29,9 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
return (
<form className="server-form" onSubmit={handleSubmit}>
<SimpleCard className="mb-3" title={title}>
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
<FormGroup value={name} onChange={setName}>Name</FormGroup>
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup>
</SimpleCard>
<div className="text-right">{children}</div>

View File

@@ -2,7 +2,7 @@ import { dissoc, values } from 'ramda';
import { CsvJson } from 'csvjson';
import LocalStorage from '../../utils/services/LocalStorage';
import { ServersMap } from '../data';
import { saveCsv } from '../../utils/helpers/csv';
import { saveCsv } from '../../utils/helpers/files';
const SERVERS_FILENAME = 'shlink-servers.csv';

View File

@@ -1,29 +1,37 @@
import { CsvJson } from 'csvjson';
import { ServerData } from '../data';
interface CsvFile extends File {
type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
}
const validateServer = (server: any): server is ServerData =>
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
const CSV_MIME_TYPES = [ 'text/csv', 'text/comma-separated-values', 'application/csv' ];
const isCsv = (file?: File | null): file is CsvFile => !!file && CSV_MIME_TYPES.includes(file.type);
const validateServers = (servers: any): servers is ServerData[] =>
Array.isArray(servers) && servers.every(validateServer);
export default class ServersImporter {
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
if (!isCsv(file)) {
throw new Error('No file provided or file is not a CSV');
if (!file) {
throw new Error('No file provided');
}
const reader = this.fileReaderFactory();
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
const content = e.target?.result?.toString() ?? '';
const servers = this.csvjson.toObject<ServerData>(content);
try {
// TODO Read as stream, otherwise, if the file is too big, this will block the browser tab
const content = e.target?.result?.toString() ?? '';
const servers = this.csvJson.toObject(content);
resolve(servers);
if (!validateServers(servers)) {
throw new Error('Provided file does not have the right format.');
}
resolve(servers);
} catch (e) {
reject(e);
}
});
reader.readAsText(file);
});

80
src/service-worker.ts Normal file
View File

@@ -0,0 +1,80 @@
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope;
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
}
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.

View File

@@ -0,0 +1,142 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

View File

@@ -34,7 +34,7 @@ const RealTimeUpdates = (
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">

View File

@@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC,
<NoMenuLayout>
<SettingsSections
items={[
[ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
[ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
]}
/>
</NoMenuLayout>

View File

@@ -1,29 +1,62 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { FC, ReactNode } from 'react';
import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
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 {
settings: Settings;
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
}
export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
{ settings: { shortUrlCreation }, setShortUrlCreationSettings },
) => (
<SimpleCard title="Short URLs creation" className="h-100">
<FormGroup className="mb-0">
<ToggleSwitch
checked={shortUrlCreation?.validateUrls ?? false}
onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
>
By default, request validation on long URLs when creating new short URLs.
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
tagFilteringMode === 'includes'
? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</>
: <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>;
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
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">
The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
</small>
</ToggleSwitch>
</FormGroup>
</SimpleCard>
);
</FormGroup>
</SimpleCard>
);
};

View File

@@ -1,9 +1,12 @@
import { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { Settings, UiSettings } from './reducers/settings';
import './UserInterface.scss';
@@ -14,17 +17,28 @@ interface UserInterfaceProps {
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
<SimpleCard title="User interface" className="h-100">
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={ui?.theme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
<FormGroup>
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={ui?.theme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ theme });
changeThemeInMarkup(theme);
}}
>
Use dark theme.
</ToggleSwitch>
setUiSettings({ ...ui, theme });
changeThemeInMarkup(theme);
}}
>
Use dark theme.
</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>
);

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.
*/
interface RealTimeUpdatesSettings {
export interface RealTimeUpdatesSettings {
enabled: boolean;
interval?: number;
}
export type TagFilteringMode = 'startsWith' | 'includes';
export interface ShortUrlCreationSettings {
validateUrls: boolean;
tagFilteringMode?: TagFilteringMode;
}
export type TagsMode = 'cards' | 'list';
export interface UiSettings {
theme: Theme;
tagsMode?: TagsMode;
}
export interface VisitsSettings {

View File

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

View File

@@ -2,7 +2,6 @@ import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
import { ShlinkPaginator } from '../api/types';
import './Paginator.scss';
interface PaginatorProps {
paginator?: ShlinkPaginator;
@@ -33,7 +32,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
));
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}>
<PaginationLink
previous

View File

@@ -1,7 +1,7 @@
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda';
import moment from 'moment';
import { parseISO } from 'date-fns';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
@@ -16,7 +16,7 @@ interface SearchBarProps {
shortUrlsListParams: ShortUrlsListParams;
}
const dateOrNull = (date?: string) => date ? moment(date) : null;
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
const selectedTags = shortUrlsListParams.tags ?? [];
@@ -30,11 +30,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
return (
<div className="search-bar-container">
<SearchField
onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
/>
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
<div className="mt-3">
<div className="row">

View File

@@ -2,15 +2,10 @@ import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import { isEmpty, pipe, replace, trim } from 'ramda';
import m from 'moment';
import classNames from 'classnames';
import { parseISO } from 'date-fns';
import DateInput, { DateInputProps } from '../utils/DateInput';
import {
supportsListingDomains,
supportsSettingShortCodeLength,
supportsShortUrlTitle,
supportsValidateUrl,
} from '../utils/helpers/features';
import { supportsCrawlableVisits, supportsShortUrlTitle } from '../utils/helpers/features';
import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import Checkbox from '../utils/Checkbox';
@@ -20,6 +15,7 @@ import { DomainSelectorProps } from '../domains/DomainSelector';
import { formatIsoDate } from '../utils/helpers/date';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { ShortUrlData } from './data';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import './ShortUrlForm.scss';
export type Mode = 'create' | 'create-basic' | 'edit';
@@ -36,11 +32,12 @@ export interface ShortUrlFormProps {
}
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>,
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 isEdit = mode === 'edit';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
@@ -72,7 +69,7 @@ export const ShortUrlForm = (
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlData[id] ? m(shortUrlData[id]) : null}
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
@@ -94,21 +91,18 @@ export const ShortUrlForm = (
</FormGroup>
<FormGroup>
<TagsSelector tags={shortUrlData.tags ?? []} onChange={changeTags} />
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
</FormGroup>
</>
);
const showDomainSelector = supportsListingDomains(selectedServer);
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
const supportsTitle = supportsShortUrlTitle(selectedServer);
const showCustomizeCard = supportsTitle || !isEdit;
const limitAccessCardClasses = classNames('mb-3', {
'col-sm-6': showCustomizeCard,
'col-sm-12': !showCustomizeCard,
});
const showValidateUrl = supportsValidateUrl(selectedServer);
const showExtraValidationsCard = showValidateUrl || !isEdit;
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
return (
<form className="short-url-form" onSubmit={submit}>
@@ -135,22 +129,16 @@ export const ShortUrlForm = (
<div className="col-lg-6">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
disabled: hasValue(shortUrlData.customSlug),
})}
</div>
</Row>
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</FormGroup>
)}
<FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</FormGroup>
</>
)}
</SimpleCard>
@@ -160,46 +148,43 @@ export const ShortUrlForm = (
<div className={limitAccessCardClasses}>
<SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
</SimpleCard>
</div>
</Row>
{showExtraValidationsCard && (
<SimpleCard title="Extra validations" className="mb-3">
{!isEdit && (
<p>
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
provided data.
</p>
)}
{showValidateUrl && (
<p>
<Checkbox
inline
checked={shortUrlData.validateUrl}
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
>
Validate URL
</Checkbox>
</p>
)}
{!isEdit && (
<p>
<Checkbox
inline
className="mr-2"
checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
)}
</SimpleCard>
)}
<SimpleCard title="Extra checks" className="mb-3">
<ShortUrlFormCheckboxGroup
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
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 })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
)}
</SimpleCard>
</>
)}

View File

@@ -70,7 +70,7 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
useEffect(() => {
const { tag } = parseQuery<{ tag?: string }>(location.search);
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags;
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
@@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
</Card>
</>
);
}, () => [ Topics.visits() ]);
}, () => [ Topics.visits ]);
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 {
cursor: pointer;
}

View File

@@ -60,7 +60,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
return (
<table className={tableClasses}>
<thead className="short-urls-table__header">
<thead className="responsive-table__header short-urls-table__header">
<tr>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
Created at

View File

@@ -1,14 +1,14 @@
import * as m from 'moment';
import { Nullable, OptionalString } from '../../utils/utils';
export interface EditShortUrlData {
longUrl?: string;
tags?: string[];
title?: string;
validSince?: m.Moment | string | null;
validUntil?: m.Moment | string | null;
validSince?: Date | string | null;
validUntil?: Date | string | null;
maxVisits?: number | null;
validateUrl?: boolean;
crawlable?: boolean;
}
export interface ShortUrlData extends EditShortUrlData {
@@ -29,6 +29,7 @@ export interface ShortUrl {
tags: string[];
domain: string | null;
title?: string | null;
crawlable?: boolean;
}
export interface ShortUrlMeta {

View File

@@ -1,31 +1,44 @@
import { useMemo, useState } from 'react';
import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap';
import { FC, useMemo, useState } from 'react';
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 classNames from 'classnames';
import { ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data';
import { DropdownBtn } from '../../utils/DropdownBtn';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features';
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
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 { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
interface QrCodeModalConnectProps extends ShortUrlModalProps {
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 [ margin, setMargin ] = useState(0);
const [ format, setFormat ] = useState<QrCodeFormat>('png');
const [ errorCorrection, setErrorCorrection ] = useState<QrErrorCorrection>('L');
const capabilities: QrCodeCapabilities = useMemo(() => ({
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
svgIsSupported: supportsQrCodeSvgFormat(selectedServer),
marginIsSupported: supportsQrCodeMargin(selectedServer),
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
}), [ selectedServer ]);
const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported;
const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin }, capabilities),
[ shortUrl, size, format, margin, capabilities ],
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
[ shortUrl, size, format, margin, errorCorrection, capabilities ],
);
const totalSize = useMemo(() => size + margin, [ size, margin ]);
const modalSize = useMemo(() => {
@@ -42,60 +55,61 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
</ModalHeader>
<ModalBody>
<Row className="mb-2">
<div
className={classNames({
'col-md-4': capabilities.marginIsSupported && capabilities.svgIsSupported,
'col-md-6': (!capabilities.marginIsSupported && capabilities.svgIsSupported) || (capabilities.marginIsSupported && !capabilities.svgIsSupported),
'col-12': !capabilities.marginIsSupported && !capabilities.svgIsSupported,
})}
<Row>
<FormGroup
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
>
<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
type="range"
className="form-control-range"
value={size}
step={10}
min={50}
max={1000}
onChange={(e) => setSize(Number(e.target.value))}
value={margin}
step={1}
min={0}
max={100}
onChange={(e) => setMargin(Number(e.target.value))}
/>
</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 && (
<div className={capabilities.marginIsSupported ? 'col-md-4' : 'col-md-6'}>
<DropdownBtn text={`Format (${format})`}>
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
</DropdownBtn>
</div>
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
<QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup>
{capabilities.errorCorrectionIsSupported && (
<FormGroup className="col-md-6">
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
</FormGroup>
)}
</Row>
<div className="text-center">
<div className="mb-3">
<div>QR code URL:</div>
<ExternalLink href={qrCodeUrl} />
<CopyToClipboardIcon text={qrCodeUrl} />
</div>
<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>
</ModalBody>
</Modal>

View File

@@ -0,0 +1,20 @@
import { ChangeEvent, FC } from 'react';
import Checkbox from '../../utils/Checkbox';
import { InfoTooltip } from '../../utils/InfoTooltip';
interface ShortUrlFormCheckboxGroupProps {
checked?: boolean;
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
infoTooltip?: string;
}
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
{ children, infoTooltip, checked, onChange },
) => (
<p>
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
{children}
</Checkbox>
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
</p>
);

View File

@@ -1,39 +1,8 @@
@import '../../utils/base';
@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 {
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 {

View File

@@ -1,6 +1,5 @@
import { isEmpty } from 'ramda';
import { FC, useEffect, useRef } from 'react';
import Moment from 'react-moment';
import { isEmpty } from 'ramda';
import { ExternalLink } from 'react-external-link';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { StateFlagTimeout } from '../../utils/helpers/hooks';
@@ -8,6 +7,7 @@ import Tag from '../../tags/helpers/Tag';
import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { ShortUrl } from '../data';
import { Time } from '../../utils/Time';
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
import './ShortUrlsRow.scss';
@@ -51,11 +51,11 @@ const ShortUrlsRow = (
}, [ shortUrl.visitsCount ]);
return (
<tr className="short-urls-row">
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
<tr className="responsive-table__row">
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
<Time date={shortUrl.dateCreated} />
</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">
<ExternalLink href={shortUrl.shortUrl} />
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
@@ -64,16 +64,16 @@ const ShortUrlsRow = (
</span>
</span>
</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>
</td>
{shortUrl.title && (
<td className="short-urls-row__cell 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} />
</td>
)}
<td className="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" data-th="Tags">{renderTags(shortUrl.tags)}</td>
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits">
<ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}
@@ -81,7 +81,7 @@ const ShortUrlsRow = (
active={active}
/>
</td>
<td className="short-urls-row__cell">
<td className="responsive-table__cell short-urls-row__cell">
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
</td>
</tr>

View File

@@ -1,13 +1,5 @@
@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 {
color: $dangerColor;

View File

@@ -1,16 +1,16 @@
import {
faChartPie as pieChartIcon,
faEllipsisV as menuIcon,
faQrcode as qrIcon,
faMinusCircle as deleteIcon,
faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import { DropdownItem } from 'reactstrap';
import { useToggle } from '../../utils/helpers/hooks';
import { ShortUrl, ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import ShortUrlDetailLink from './ShortUrlDetailLink';
import './ShortUrlsRowMenu.scss';
@@ -29,32 +29,27 @@ const ShortUrlsRowMenu = (
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
return (
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right>
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
<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">
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
</DropdownItem>
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
</DropdownItem>
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
<DropdownItem divider />
<DropdownItem divider />
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
</DropdownMenu>
</ButtonDropdown>
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
</DropdownBtnMenu>
);
};

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 { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
@@ -24,17 +25,13 @@ export interface CreateShortUrlAction extends Action<string> {
result: ShortUrl;
}
export interface CreateShortUrlFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlCreation = {
result: null,
saving: 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_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[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 });
} catch (e) {
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types';
import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
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;
}
interface DeleteShortUrlErrorAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlDeletion = {
shortCode: '',
loading: 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_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
@@ -52,7 +49,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
await deleteShortUrl(shortCode, domain);
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
} catch (e) {
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -7,6 +7,7 @@ import { GetState } from '../../container/types';
import { shortUrlMatches } from '../helpers';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
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;
}
export interface ShortUrlDetailFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlDetail = {
loading: 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_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
@@ -54,6 +51,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
} 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 { parseApiError } from '../../api/utils';
import { supportsTagsInPatch } from '../../utils/helpers/features';
import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
@@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action<string> {
shortUrl: ShortUrl;
}
export interface ShortUrlEditionFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlEdition = {
saving: 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_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[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 });
} catch (e) {
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -51,7 +51,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
// Services

View File

@@ -1,11 +1,10 @@
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
import { FC } from 'react';
import { FC, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks';
import { Versions } from '../utils/helpers/version';
import ColorGenerator from '../utils/services/ColorGenerator';
import { isServerWithId, SelectedServer } from '../servers/data';
import TagBullet from './helpers/TagBullet';
@@ -20,17 +19,24 @@ export interface TagCardProps {
toggle: () => void;
}
const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth;
const TagCard = (
DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>,
ForServerVersion: FC<Versions>,
colorGenerator: ColorGenerator,
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
const [ hasTitle,, displayTitle ] = useToggle();
const titleRef = useRef<HTMLElement>();
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${tag}`;
useEffect(() => {
if (isTruncated(titleRef.current)) {
displayTitle();
}
}, [ titleRef.current ]);
return (
<Card className="tag-card">
@@ -41,14 +47,15 @@ const TagCard = (
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</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} />
<ForServerVersion minVersion="2.2.0">
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
</ForServerVersion>
<ForServerVersion maxVersion="2.1.*">
<Link to={shortUrlsLink}>{tag}</Link>
</ForServerVersion>
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
</h5>
</CardHeader>
@@ -56,7 +63,7 @@ const TagCard = (
<Collapse isOpen={displayed}>
<CardBody className="tag-card__body">
<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"
>
<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 { splitEvery } from 'ramda';
import { Row } from 'reactstrap';
import Message from '../utils/Message';
import SearchField from '../utils/SearchField';
import { SelectedServer } from '../servers/data';
@@ -7,33 +7,33 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { Topics } from '../mercure/helpers/Topics';
import { Settings, TagsMode } from '../settings/reducers/settings';
import { TagsList as TagsListState } from './reducers/tagsList';
import { TagCardProps } from './TagCard';
const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4;
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
import { TagsModeDropdown } from './TagsModeDropdown';
export interface TagsListProps {
filterTags: (searchTerm: string) => void;
forceListTags: Function;
tagsList: TagsListState;
selectedServer: SelectedServer;
settings: Settings;
}
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => {
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
useEffect(() => {
forceListTags();
}, []);
const renderContent = () => {
if (tagsList.loading) {
return <Message loading />;
}
if (tagsList.loading) {
return <Message loading />;
}
const renderContent = () => {
if (tagsList.error) {
return (
<Result type="error">
@@ -42,40 +42,26 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
);
}
const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) {
if (tagsList.filteredTags.length < 1) {
return <Message>No tags found</Message>;
}
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
return (
<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 mode === 'cards'
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
};
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()}
</>
);
}, () => [ Topics.visits() ]);
}, () => [ Topics.visits ]);
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 { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { ChromePicker } from 'react-color';
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -25,10 +25,12 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
const [ color, setColor ] = useState(getColorForKey(tag));
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
const { editing, error, errorData } = tagEdit;
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
.then(() => tagEdited(tag, newTagName, color))
.then(toggle)
.catch(() => {}));
const saveTag = handleEventPreventingDefault(
async () => editTag(tag, newTagName, color)
.then(() => tagEdited(tag, newTagName, color))
.then(toggle)
.catch(() => {}),
);
return (
<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">
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
</Popover>
<input
type="text"
<Input
value={newTagName}
placeholder="Tag"
required
className="form-control"
onChange={(e) => setNewTagName(e.target.value)}
onChange={({ target }) => setNewTagName(target.value)}
/>
</div>
@@ -64,8 +64,8 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
</ModalFooter>
</form>
</Modal>

View File

@@ -1,18 +1,19 @@
import { FC } from 'react';
import { FC, MouseEventHandler } from 'react';
import ColorGenerator from '../../utils/services/ColorGenerator';
import './Tag.scss';
interface TagProps {
colorGenerator: ColorGenerator;
text: string;
className?: string;
clearable?: boolean;
onClick?: () => void;
onClose?: () => void;
onClick?: MouseEventHandler;
onClose?: MouseEventHandler;
}
const Tag: FC<TagProps> = ({ text, children, clearable, colorGenerator, onClick, onClose }) => (
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
<span
className="badge tag"
className={`badge tag ${className}`}
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
onClick={onClick}
>

View File

@@ -1,16 +0,0 @@
@import '../../utils/base';
.react-autosuggest__suggestions-list {
list-style-type: none;
padding: 0;
margin-bottom: 6px;
}
.react-autosuggest__suggestion {
margin-left: -6px;
padding: 5px 8px;
}
.react-autosuggest__suggestion--highlighted {
background-color: $lightGrey;
}

View File

@@ -1,13 +1,13 @@
import { ChangeEvent, useEffect } from 'react';
import TagsInput, { RenderInputProps, RenderTagProps } from 'react-tagsinput';
import Autosuggest, { ChangeEvent as AutoChangeEvent, SuggestionSelectedEventData } from 'react-autosuggest';
import { useEffect } from 'react';
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { Settings } from '../../settings/reducers/settings';
import { TagsList } from '../reducers/tagsList';
import TagBullet from './TagBullet';
import './TagsSelector.scss';
import Tag from './Tag';
export interface TagsSelectorProps {
tags: string[];
selectedTags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}
@@ -15,67 +15,55 @@ export interface TagsSelectorProps {
interface TagsSelectorConnectProps extends TagsSelectorProps {
listTags: Function;
tagsList: TagsList;
settings: Settings;
}
const noop = () => {};
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
const TagsSelector = (colorGenerator: ColorGenerator) => (
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
{ selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps,
) => {
useEffect(() => {
listTags();
}, []);
const renderTag = (
{ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }: RenderTagProps<string>,
) => (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)}
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
</span>
const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
<>
<TagBullet tag={`${item.name}`} colorGenerator={colorGenerator} />
{item.name}
</>
);
const renderAutocompleteInput = (data: RenderInputProps<string>) => {
const { addTag, ...otherProps } = data;
const handleOnChange = (e: ChangeEvent<HTMLInputElement>, { method }: AutoChangeEvent) => {
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
};
const inputValue = otherProps.value?.trim().toLowerCase() ?? '';
const suggestions = tagsList.tags.filter((tag) => tag.startsWith(inputValue));
return (
<Autosuggest
ref={otherProps.ref}
suggestions={suggestions}
inputProps={{ ...otherProps, onChange: handleOnChange }}
highlightFirstSuggestion
shouldRenderSuggestions={(value: string) => value.trim().length > 0}
getSuggestionValue={(suggestion) => suggestion}
renderSuggestion={(suggestion) => (
<>
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
{suggestion}
</>
)}
onSuggestionsFetchRequested={noop}
onSuggestionsClearRequested={noop}
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
addTag(suggestion);
}}
/>
);
};
return (
<TagsInput
value={tags}
inputProps={{ placeholder }}
onlyUnique
renderTag={renderTag}
renderInput={renderAutocompleteInput}
// FIXME Workaround to be able to add tags on Android
<ReactTags
tags={selectedTags.map(toComponentTag)}
tagComponent={ReactTagsTag}
suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)}
suggestionComponent={ReactTagsSuggestion}
allowNew
addOnBlur
onChange={onChange}
placeholderText={placeholder ?? 'Add tags to the URL'}
minQueryLength={1}
delimiters={[ 'Enter', 'Tab', ',' ]}
suggestionsTransform={
searchMode === 'includes'
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
: undefined
}
onDelete={(removedTagIndex) => {
const tagsCopy = [ ...selectedTags ];
tagsCopy.splice(removedTagIndex, 1);
onChange(tagsCopy);
}}
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 { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
@@ -22,16 +23,12 @@ export interface DeleteTagAction extends Action<string> {
tag: string;
}
export interface DeleteTagFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: TagDeletion = {
deleting: false,
error: false,
};
export default buildReducer<TagDeletion, DeleteTagFailedAction>({
export default buildReducer<TagDeletion, ApiErrorAction>({
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
[DELETE_TAG]: () => ({ deleting: false, error: false }),
@@ -48,7 +45,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
await deleteTags([ tag ]);
dispatch({ type: DELETE_TAG });
} catch (e) {
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
@@ -29,10 +30,6 @@ export interface EditTagAction extends Action<string> {
color: string;
}
export interface EditTagFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: TagEdition = {
oldName: '',
newName: '',
@@ -40,7 +37,7 @@ const initialState: TagEdition = {
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_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
[EDIT_TAG]: (_, action) => ({
@@ -63,7 +60,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) {
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -8,6 +8,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
import { CreateVisit, Stats } from '../../visits/types';
import { parseApiError } from '../../api/utils';
import { TagStats } from '../data';
import { ApiErrorAction } from '../../api/types/actions';
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
import { EditTagAction, TAG_EDITED } from './tagEdit';
@@ -34,20 +35,16 @@ interface ListTagsAction extends Action<string> {
stats: TagsStatsMap;
}
interface ListTagsFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
interface FilterTagsAction extends Action<string> {
searchTerm: string;
}
type ListTagsCombinedAction = ListTagsAction
type TagsCombinedAction = ListTagsAction
& DeleteTagAction
& CreateVisitsAction
& EditTagAction
& FilterTagsAction
& ListTagsFailedAction;
& ApiErrorAction;
const initialState = {
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_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[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 });
} 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 { withRouter } from 'react-router-dom';
import TagsSelector from '../helpers/TagsSelector';
import TagCard from '../TagCard';
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
@@ -8,20 +9,16 @@ import { filterTags, listTags } from '../reducers/tagsList';
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
import { editTag, tagEdited } from '../reducers/tagEdit';
import { ConnectDecorator } from '../../container/types';
import { TagsCards } from '../TagsCards';
import { TagsTable } from '../TagsTable';
import { TagsTableRow } from '../TagsTableRow';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ]));
bottle.serviceFactory(
'TagCard',
TagCard,
'DeleteTagConfirmModal',
'EditTagModal',
'ForServerVersion',
'ColorGenerator',
);
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
@@ -29,9 +26,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
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(
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
));

View File

@@ -1,33 +1,12 @@
import { useRef } from 'react';
import { isNil, dissoc } from 'ramda';
import { isNil } from 'ramda';
import DatePicker, { ReactDatePickerProps } from 'react-datepicker';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
import classNames from 'classnames';
import moment from 'moment';
import './DateInput.scss';
interface DatePropsInterface {
endDate?: moment.Moment | null;
maxDate?: moment.Moment | null;
minDate?: moment.Moment | null;
selected?: moment.Moment | null;
startDate?: moment.Moment | null;
onChange?: (date: moment.Moment | null) => void;
}
export type DateInputProps = DatePropsInterface & Omit<ReactDatePickerProps, keyof DatePropsInterface>;
const transformProps = (props: DateInputProps): ReactDatePickerProps => ({
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop
...dissoc('ref', props),
endDate: props.endDate?.toDate(),
maxDate: props.maxDate?.toDate(),
minDate: props.minDate?.toDate(),
selected: props.selected?.toDate(),
startDate: props.startDate?.toDate(),
onChange: (date: Date | null) => props.onChange?.(date && moment(date)),
});
export type DateInputProps = ReactDatePickerProps;
const DateInput = (props: DateInputProps) => {
const { className, isClearable, selected } = props;
@@ -37,7 +16,7 @@ const DateInput = (props: DateInputProps) => {
return (
<div className="date-input-container">
<DatePicker
{...transformProps(props)}
{...props}
dateFormat="yyyy-MM-dd"
className={classNames('date-input-container__input form-control', className)}
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop

View File

@@ -9,18 +9,20 @@ export interface DropdownBtnProps {
className?: string;
dropdownClassName?: string;
right?: boolean;
minWidth?: number;
}
export const DropdownBtn: FC<DropdownBtnProps> = (
{ text, disabled = false, className = '', children, dropdownClassName, right = false },
{ text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth },
) => {
const [ isOpen, toggle ] = useToggle();
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
const style = { minWidth: minWidth && `${minWidth}px` };
return (
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
<DropdownMenu className="w-100" right={right}>{children}</DropdownMenu>
<DropdownMenu className="w-100" right={right} style={style}>{children}</DropdownMenu>
</Dropdown>
);
};

View File

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

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