Compare commits

...

226 Commits

Author SHA1 Message Date
Alejandro Celaya
e26cdc11c3 Merge pull request #283 from acelaya-forks/feature/chart-legend
Feature/chart legend
2020-06-06 12:27:01 +02:00
Alejandro Celaya
fa54aa3128 Updated changelog 2020-06-06 12:17:45 +02:00
Alejandro Celaya
e31e70039d Created GraphCard test 2020-06-06 12:16:19 +02:00
Alejandro Celaya
cb761dea8f Increased default height for doughnut charts 2020-06-06 12:08:21 +02:00
Alejandro Celaya
949e0da105 Added custom responsive legend to doughnut charts 2020-06-06 11:58:25 +02:00
Alejandro Celaya
770cc59448 Extracted logic to render graph from GraphCard to DefatlChart component 2020-06-06 10:35:13 +02:00
Alejandro Celaya
72dd2bd0a7 Merge pull request #282 from acelaya-forks/feature/mercure-dup-code
Feature/mercure dup code
2020-06-06 09:47:15 +02:00
Alejandro Celaya
54733eaa18 Updated changelog 2020-06-06 09:30:39 +02:00
Alejandro Celaya
52c56f7918 Created custom react hook that binds to mercure topic 2020-06-06 09:29:43 +02:00
Alejandro Celaya
c46d5187c1 Removed duplicated code when binding to mercure by checking if enabled first 2020-06-06 09:24:05 +02:00
Alejandro Celaya
05e3e87653 Merge pull request #281 from acelaya-forks/feature/docker-version
Fixed version not properly provided to docker image
2020-06-06 08:58:41 +02:00
Alejandro Celaya
8b9289ff08 Fixed version not properly provided to docker image 2020-06-06 08:50:37 +02:00
Alejandro Celaya
16ffbcfbc0 Merge pull request #278 from acelaya-forks/feature/fix-default-grouping
Feature/fix default grouping
2020-05-31 20:30:32 +02:00
Alejandro Celaya
d825b6e174 Updated changelog 2020-05-31 20:17:59 +02:00
Alejandro Celaya
73e55cc742 Replaced if/else by functional matcher 2020-05-31 20:16:15 +02:00
Alejandro Celaya
32cc1cc580 Improved logic to determine default grouping for line chart based on how old the visits are 2020-05-31 20:03:59 +02:00
Alejandro Celaya
e00574553f Moved some helper components for visits to visits/helpers 2020-05-31 17:51:52 +02:00
Alejandro Celaya
984c1ea716 Added v2.5.0 to changelog 2020-05-31 12:00:50 +02:00
Alejandro Celaya
df38cf6ca9 Merge pull request #275 from acelaya-forks/feature/remove-class-components
Feature/remove class components
2020-05-31 11:51:08 +02:00
Alejandro Celaya
1b60b0e2a8 Updated SortableBarGraph to be a functional component 2020-05-31 11:36:56 +02:00
Alejandro Celaya
11f9c7c507 Updated TagsSelector to be a functional component 2020-05-31 11:19:53 +02:00
Alejandro Celaya
ebe649aaac Updated EditTagModal to be a functional component 2020-05-31 11:06:23 +02:00
Alejandro Celaya
656b68d422 Updated DeleteTagConfirmModal to be a functional component 2020-05-31 10:45:18 +02:00
Alejandro Celaya
cd1f186e28 Updated EditTagsModal to be a functional component 2020-05-31 10:31:00 +02:00
Alejandro Celaya
d0b3edaa2f Updated DeleteShortUrlModal to be a functional component 2020-05-31 10:23:13 +02:00
Alejandro Celaya
2268b85ade Updated CreateShortUrlResult to be a functional component 2020-05-31 10:16:09 +02:00
Alejandro Celaya
d7e3b7b912 Updated ImportServersBtn to be a functional component 2020-05-31 09:58:27 +02:00
Alejandro Celaya
4bd83eecfb Updated ScrollToTop to be a functional component 2020-05-31 09:50:00 +02:00
Alejandro Celaya
b7fd2308ad Merge pull request #274 from acelaya-forks/feature/stryker3
Updated to stryker 3
2020-05-31 09:34:02 +02:00
Alejandro Celaya
a6958941ad Updated to stryker 3 2020-05-31 09:27:42 +02:00
Alejandro Celaya
c98b28ff0f Merge pull request #273 from acelaya-forks/feature/charts-improvements
Feature/charts improvements
2020-05-31 09:19:02 +02:00
Alejandro Celaya
6a372badfa Improved labels displayed in charts when visits are highlighted 2020-05-31 09:07:21 +02:00
Alejandro Celaya
b6ab9a1bdd Improved memoization of grouped visits for line chart 2020-05-31 08:55:52 +02:00
Alejandro Celaya
daf9e7cf64 Merge pull request #272 from acelaya-forks/feature/line-chart-improvements
Some improvements on LineChartCard
2020-05-30 17:52:55 +02:00
Alejandro Celaya
ef42dcd666 Simplified code and removed duplication 2020-05-30 17:43:13 +02:00
Alejandro Celaya
1b6028ae6d Some improvements on LineChartCard 2020-05-30 17:39:08 +02:00
Alejandro Celaya
9340512980 Merge pull request #271 from acelaya-forks/feature/line-chart
Feature/line chart
2020-05-30 10:54:27 +02:00
Alejandro Celaya
9d0b4cc065 Updated changelog 2020-05-30 10:43:18 +02:00
Alejandro Celaya
c5cb0dcb26 Added test for LineChartCard 2020-05-30 10:41:46 +02:00
Alejandro Celaya
a42f5ab13e Set fixed height for time-based line chart 2020-05-30 10:26:52 +02:00
Alejandro Celaya
68b0577526 Added dynamic grouping to time-based line chart 2020-05-30 09:57:21 +02:00
Alejandro Celaya
61867366e7 Created first version of the time-based visits chart 2020-05-30 09:25:15 +02:00
Alejandro Celaya
c670d86955 Merge pull request #270 from acelaya-forks/feature/parallel-docker-build
Feature/parallel docker build
2020-05-17 10:37:34 +02:00
Alejandro Celaya
4565a64cd8 Rolled back node version for scrutinizer 2020-05-17 10:28:49 +02:00
Alejandro Celaya
f36e42d9c1 Fixed travis config error 2020-05-17 10:16:53 +02:00
Alejandro Celaya
0a3a97242b Simplified docker image building, as it will now be run in a different job as the dist file release 2020-05-17 10:13:33 +02:00
Alejandro Celaya
68253c3bc4 Disabled multi-arch building until everything is compatible 2020-05-17 10:12:39 +02:00
Alejandro Celaya
544384d85e Updated runtimne versions 2020-05-17 10:04:05 +02:00
Alejandro Celaya
91daec852f Added parallel docker image multi-arch building 2020-05-17 10:02:36 +02:00
Alejandro Celaya
dcc5b9cc8c Merge pull request #268 from acelaya-forks/feature/tag-visits
Feature/tag visits
2020-05-13 20:41:39 +02:00
Alejandro Celaya
1d26cd93fb Added real time updates to tags list page 2020-05-13 18:32:27 +02:00
Alejandro Celaya
e47dfaf36f Changed paginable charts so that they use 50 items per page by default 2020-05-11 19:44:27 +02:00
Alejandro Celaya
09e2c69e46 Ensured visits by tag route does not work for old Shlink servers 2020-05-11 19:40:19 +02:00
Alejandro Celaya
07d3567244 Added progress bar to visits page when loading a lot of visits 2020-05-11 19:32:42 +02:00
Alejandro Celaya
9bdbe90716 Ensured state is properly reset when starting, finisihing or failing to load visits 2020-05-11 18:55:35 +02:00
Alejandro Celaya
02a4380f7c Updated changelog 2020-05-10 20:36:03 +02:00
Alejandro Celaya
4e483dc5d4 Created TagVisits test 2020-05-10 20:30:19 +02:00
Alejandro Celaya
52631e629e Created TagVisitsHeader test 2020-05-10 20:23:45 +02:00
Alejandro Celaya
3a53298417 Improved visits pages titles 2020-05-10 20:17:17 +02:00
Alejandro Celaya
fb0f14fc16 Created header for visits by tag section 2020-05-10 19:49:58 +02:00
Alejandro Celaya
7a94b1730d Created common component for visits header 2020-05-10 19:37:00 +02:00
Alejandro Celaya
f856bc218a Created tagVisits reducer test 2020-05-10 19:12:18 +02:00
Alejandro Celaya
bfbb21e1cc Created page for tag visit stats 2020-05-10 19:02:58 +02:00
Alejandro Celaya
18e18f533b Extracted visits charts elements into reusable component 2020-05-10 17:49:55 +02:00
Alejandro Celaya
6eead70511 Merge pull request #266 from acelaya-forks/feature/tags-list-improvements
Feature/tags list improvements
2020-05-10 11:34:48 +02:00
Alejandro Celaya
6fd30ed51a Improved how tags are exposed by the ApiClient when listing tags 2020-05-10 11:20:40 +02:00
Alejandro Celaya
67c674f073 Updated changelog 2020-05-10 11:15:24 +02:00
Alejandro Celaya
289d8784c0 Converted TagCard component into functional component 2020-05-10 11:12:22 +02:00
Alejandro Celaya
18e026e4ca Updated tags list to display visits and short URLs when remote shlink version allows it 2020-05-10 10:57:49 +02:00
Alejandro Celaya
8741f42fe8 Merge pull request #264 from acelaya-forks/feature/charts-precision
Feature/charts precision
2020-05-07 11:21:51 +02:00
Alejandro Celaya
665d6209d9 Updated changelog 2020-05-07 11:10:10 +02:00
Alejandro Celaya
59fda29894 Added precision 0 to charts, to avoid having decimals 2020-05-07 11:06:14 +02:00
Alejandro Celaya
61c027f9a1 Merge pull request #263 from acelaya-forks/feature/minor-improvements
Minor improvements
2020-05-03 20:23:24 +02:00
Alejandro Celaya
241c9b73b0 Minor improvements 2020-05-03 20:16:21 +02:00
Alejandro Celaya
85dc1d0825 Merge pull request #260 from acelaya-forks/feature/responsive-visits-table
Feature/responsive visits table
2020-04-28 19:08:20 +02:00
Alejandro Celaya
e38887aa26 Ensured side menu is not swippoed when horizontally scrolling visits table 2020-04-28 18:54:58 +02:00
Alejandro Celaya
54fec79945 First minor improvements to the visits table responsiveness 2020-04-27 18:04:24 +02:00
Alejandro Celaya
fad0bf1c9d Merge pull request #258 from acelaya-forks/feature/redux-localstorage
Feature/redux localstorage
2020-04-27 13:49:05 +02:00
Alejandro Celaya
be2f86050f Updated changelog 2020-04-27 13:37:54 +02:00
Alejandro Celaya
a7f941e8e4 Deleted no-longer-needed ServersService 2020-04-27 13:21:07 +02:00
Alejandro Celaya
b08c6748c7 Moved remote servers loading to separated action 2020-04-27 12:54:52 +02:00
Alejandro Celaya
bdd7932e07 Refactored ServersDropdown into functional component 2020-04-27 12:30:17 +02:00
Alejandro Celaya
bcf5dcf180 Converted server handling actions into regular actions 2020-04-27 11:30:51 +02:00
Alejandro Celaya
8b2cbf7aea Some minor refactorings 2020-04-27 10:52:19 +02:00
Alejandro Celaya
277b5e43f8 Flatten model holding list of servers 2020-04-27 10:49:55 +02:00
Alejandro Celaya
7dd6a31609 Deleted SettingsService, as the task is not transparently handled by a redux middleware 2020-04-26 19:07:47 +02:00
Alejandro Celaya
86bf1515d4 Added redux middleware to save parts of the store in the local storage transparently 2020-04-26 19:04:17 +02:00
Alejandro Celaya
bbc47b387e Created single reducer to handle settings 2020-04-26 13:00:27 +02:00
Alejandro Celaya
3953e98a77 Merge pull request #257 from acelaya-forks/feature/navigate-back
Feature/navigate back
2020-04-26 12:02:54 +02:00
Alejandro Celaya
09b8bd501d Converted TagsList component into functional component 2020-04-26 11:48:08 +02:00
Alejandro Celaya
6bddaaa055 Added cancel button to edit server page 2020-04-26 10:56:27 +02:00
Alejandro Celaya
dd728d4d13 Added back button to visits stats page 2020-04-26 10:43:00 +02:00
Alejandro Celaya
9ba8bc8f3d Merge pull request #256 from acelaya-forks/feature/settings-page
Feature/settings page
2020-04-25 11:19:19 +02:00
Alejandro Celaya
16dee3664b Ensured mercure updates are not set even if supported, when they have been disabled 2020-04-25 10:37:50 +02:00
Alejandro Celaya
6fcf588bfd Updated changelog 2020-04-25 10:23:51 +02:00
Alejandro Celaya
6a6c427b0e Added unit tests for settings business logic elements 2020-04-25 10:22:17 +02:00
Alejandro Celaya
41f885d8ec Created settings page and reducers to handle real-time updates config 2020-04-25 09:49:54 +02:00
Alejandro Celaya
7516ca8dd9 Created settings page and converted MainHeader into functional component 2020-04-18 20:58:35 +02:00
Alejandro Celaya
aa59a95f91 Merge pull request #251 from acelaya-forks/feature/real-time-updates
Feature/real time updates
2020-04-18 20:57:07 +02:00
Alejandro Celaya
8a5161c0e8 Updated changelog 2020-04-18 20:36:49 +02:00
Alejandro Celaya
d8ae69e861 Added test for mercure info helpers 2020-04-18 12:49:03 +02:00
Alejandro Celaya
a485d0b507 Added token expired handling to mercure binding 2020-04-18 12:26:00 +02:00
Alejandro Celaya
ed40b79c8d Added more tests covering new use cases 2020-04-18 12:09:51 +02:00
Alejandro Celaya
91488ae294 Fixed visits count not handling separated tooltiups 2020-04-18 11:03:49 +02:00
Alejandro Celaya
a22a1938c1 Added automatic refresh on mercure events 2020-04-18 10:50:01 +02:00
Alejandro Celaya
0f73cb9f8c Converted short URLs list in functional component 2020-04-17 17:39:30 +02:00
Alejandro Celaya
f3129399de Added EventSource connection to mercure hub possible 2020-04-17 17:11:52 +02:00
Alejandro Celaya
37e6c27461 Created mercure info reducer and loaded info when server is reachable 2020-04-17 15:51:18 +02:00
Alejandro Celaya
d231ed3ede Merge pull request #247 from acelaya-forks/feature/user-agent-improvements
Feature/user agent improvements
2020-04-10 20:06:57 +02:00
Alejandro Celaya
cf6f9028f2 Some more improvements on how chart height is calculated 2020-04-10 19:57:33 +02:00
Alejandro Celaya
7cf49d2c1a Increased minimum charts height 2020-04-10 19:47:42 +02:00
Alejandro Celaya
e37fb1b4bd Updated changelog 2020-04-10 19:29:57 +02:00
Alejandro Celaya
faf5d0bf7b Unified function parsing user agent for browser and os 2020-04-10 19:22:13 +02:00
Alejandro Celaya
6fede88072 Added dependency on bowser to have a more accurate browser and OS detection 2020-04-10 19:16:44 +02:00
Alejandro Celaya
87ffbefa61 Merge pull request #246 from acelaya-forks/feature/create-improvements
Feature/create improvements
2020-04-10 18:50:09 +02:00
Alejandro Celaya
f33ae17781 Updated changelog 2020-04-10 18:43:16 +02:00
Alejandro Celaya
2a2bae6d1a Improved short URL creation 2020-04-10 18:42:08 +02:00
Alejandro Celaya
eb65e99024 Merge pull request #244 from acelaya-forks/feature/chart-visit-highlighting
Feature/chart visit highlighting
2020-04-10 15:21:07 +02:00
Alejandro Celaya
52dbeb6201 Optimized visits parser to act over the normalized list of visits 2020-04-10 14:59:12 +02:00
Alejandro Celaya
fafe920b7b Ensured highlighted stats are properly sorted and paginated on charts that support that 2020-04-10 14:38:31 +02:00
Alejandro Celaya
9d1e48ee90 Updated main list paginator to be sticky 2020-04-10 13:42:21 +02:00
Alejandro Celaya
3851342e1b Added button to reset visits selection 2020-04-10 13:27:01 +02:00
Alejandro Celaya
b863c2e19d Used cursor pointer in bar charts 2020-04-10 13:04:39 +02:00
Alejandro Celaya
ed584d19e5 Ensured charts datasets have a unique label 2020-04-10 12:57:14 +02:00
Alejandro Celaya
73256dcf5b Handled toggling between highlighted chart bars 2020-04-10 12:53:54 +02:00
Alejandro Celaya
c67a23c988 Added support to disable date inputs 2020-04-10 12:25:06 +02:00
Alejandro Celaya
8f42e65ccd Allowed visits to be selected on charts so that they get highlighted on the rest of the charts 2020-04-10 11:59:53 +02:00
Alejandro Celaya
05deb1aff0 Merge pull request #242 from acelaya-forks/feature/visits-table
Feature/visits table
2020-04-09 11:23:51 +02:00
Alejandro Celaya
a74b7cdfad Updated changelog 2020-04-09 11:00:27 +02:00
Alejandro Celaya
1c3119ee76 Allowed multiple selection on visits table 2020-04-09 10:56:54 +02:00
Alejandro Celaya
ca52911e42 Added VisitsTable test 2020-04-09 10:21:38 +02:00
Alejandro Celaya
9177bc7cef Tested how hilghlighted data behaves on GraphCards 2020-04-09 09:44:14 +02:00
Alejandro Celaya
310831a26a Converted ShortUrlVisits in functional component 2020-04-07 22:33:41 +02:00
Alejandro Celaya
8a486d991b Implemented some improvements and fixes on how visits table is split and calculated 2020-04-05 18:04:15 +02:00
Alejandro Celaya
b79333393b Converted SearchField component into funcitonal component 2020-04-05 16:18:08 +02:00
Alejandro Celaya
cb7062bb95 Created fake border with before and after pseudoelements for sticky table cells 2020-04-05 16:02:42 +02:00
Alejandro Celaya
94c5b2c471 Improved useToggle hook so that it also returns enabler and disabler 2020-04-05 12:18:41 +02:00
Alejandro Celaya
66bf26f1dc Improved highlighted data calculation so that it works with values different than 1 2020-04-05 11:57:39 +02:00
Alejandro Celaya
f5cc1abe75 Ensured info for selected visit in visits table gets highlighted in bar charts 2020-04-04 20:16:20 +02:00
Alejandro Celaya
bd4255108d Improved VisitsTable performance by memoizing visits lists 2020-04-04 12:58:04 +02:00
Alejandro Celaya
06b63d1af2 Improved rendering of visits table on mobile devices 2020-04-04 12:09:17 +02:00
Alejandro Celaya
2bd70fb9e6 Fixed unit tests 2020-04-04 10:36:38 +02:00
Alejandro Celaya
e6034dfb14 Created VisitsTable 2020-04-03 23:00:57 +02:00
Alejandro Celaya
c8ba6764c2 Merge pull request #238 from acelaya-forks/feature/edit-long-url
Feature/edit long url
2020-03-30 21:36:20 +02:00
Alejandro Celaya
19337d6c05 Added tests for elements regarding short URL edition 2020-03-30 21:26:30 +02:00
Alejandro Celaya
a6ad3c2d4d Updated changelog 2020-03-30 21:01:54 +02:00
Alejandro Celaya
b0dd885c09 Converted ShortUrlsRowMenu into functional component 2020-03-30 21:01:01 +02:00
Alejandro Celaya
2235592308 Fixed ShortUrlsRowMenu test 2020-03-30 20:50:31 +02:00
Alejandro Celaya
1219a16261 Ensured short URLs list is updated after editing the long URL of a short URL 2020-03-30 20:47:33 +02:00
Alejandro Celaya
7949e224e0 Created modal to edit the loing URL behind a short URL 2020-03-30 20:42:58 +02:00
Alejandro Celaya
ab2f311bb7 Merge pull request #237 from acelaya-forks/feature/short-code-length
Feature/short code length
2020-03-29 19:49:09 +02:00
Alejandro Celaya
a5aab43666 Updated changelog 2020-03-29 19:41:29 +02:00
Alejandro Celaya
74ebd4e572 Converted CreateShortUrl to functional component 2020-03-29 19:36:45 +02:00
Alejandro Celaya
bd29670108 Added short code length field to form to create short URLs 2020-03-29 18:55:41 +02:00
Alejandro Celaya
9a20b4428d Merge pull request #236 from acelaya-forks/feature/progressive-paginator
Feature/progressive paginator
2020-03-28 17:52:35 +01:00
Alejandro Celaya
d7da8521ce Created helper functions to determine the key and if a page is disabled on a progressive paginator 2020-03-28 17:43:09 +01:00
Alejandro Celaya
bab3b252c1 Updated changelog 2020-03-28 17:35:02 +01:00
Alejandro Celaya
7f05c5c2da Split utils module into several helpers modules 2020-03-28 17:33:27 +01:00
Alejandro Celaya
2d5c2779c3 Moved helper functions to render progressive paginators to a common place 2020-03-28 17:25:12 +01:00
Alejandro Celaya
06db4f6556 Used progressive pagination for the short URLs list 2020-03-28 17:19:33 +01:00
Alejandro Celaya
ea5ec63a22 Ensured all branches build the docker image 2020-03-22 09:34:24 +01:00
Alejandro Celaya
f46e737e77 Merge pull request #233 from acelaya-forks/feature/fix-docker-build-condition
Fixed docker build condition so that it's run for any branch or tag a…
2020-03-21 13:39:31 +01:00
Alejandro Celaya
6e63bdaafa Fixed docker build condition so that it's run for any branch or tag as long as it is not a PR 2020-03-21 08:43:35 +01:00
Alejandro Celaya
79ccef9f7e Avoid latest docker to be build when building a tag 2020-03-21 06:59:37 +01:00
Alejandro Celaya
a9653b3674 Merge pull request #232 from acelaya-forks/feature/improve-docker-build
Feature/improve docker build
2020-03-20 09:23:35 +01:00
Alejandro Celaya
b5a188e802 Improved building process so that already generated dist files are reused when building docker image is possible 2020-03-20 09:12:43 +01:00
Alejandro Celaya
38fc402b16 Improved docker build script to avoid duplicating code 2020-03-20 07:12:07 +01:00
Alejandro Celaya
584d1ec1ce Fixed conditional in docker build script 2020-03-19 20:39:34 +01:00
Alejandro Celaya
2ca7faa457 Merge pull request #230 from acelaya-forks/feature/travis-docker-build
Added docker image building as a deployment step for travis
2020-03-19 20:32:00 +01:00
Alejandro Celaya
03806abda0 Changed build steps so that mutation testing a docker build are only run on pull request builds 2020-03-19 20:26:35 +01:00
Alejandro Celaya
18d125430d Added docker image building as a deployment step for travis 2020-03-19 20:04:30 +01:00
Alejandro Celaya
f57f6b7745 Merge pull request #228 from acelaya-forks/feature/memoize-server-version
Feature/memoize server version
2020-03-16 19:01:33 +01:00
Alejandro Celaya
75ff2b8f40 Added app gif to readme 2020-03-16 18:53:06 +01:00
Alejandro Celaya
2ec04c0121 Fixed test by using different serverId every time, preventing memoization 2020-03-16 18:51:04 +01:00
Alejandro Celaya
5145a41dac Memoized the loading of the server version, assuming it will not change at runtime 2020-03-16 13:34:24 +01:00
Alejandro Celaya
25c67f1c3e Merge pull request #227 from acelaya-forks/feature/edit-servers
Feature/edit servers
2020-03-15 14:32:30 +01:00
Alejandro Celaya
77b9181150 Replaced hardcoded color by sass var 2020-03-15 14:23:57 +01:00
Alejandro Celaya
e4f7ded8e2 Updated changelog 2020-03-15 14:04:33 +01:00
Alejandro Celaya
35a62f1fb1 Added link to edit existing servers 2020-03-15 14:03:41 +01:00
Alejandro Celaya
24f2deda46 Moved common code to handle currently selected server to HOC 2020-03-15 13:43:12 +01:00
Alejandro Celaya
5d8af1a0e5 Simplified EditServer component by wrapping ServerForm 2020-03-15 12:02:19 +01:00
Alejandro Celaya
6d44ac1e0c Created common component that can be used both for create and edit servers 2020-03-15 11:59:07 +01:00
Alejandro Celaya
fb0ebddf28 Created component to edit existing servers 2020-03-15 11:29:20 +01:00
Alejandro Celaya
0aebaa4da1 Extracted logic to render horizontal form groups to their own components 2020-03-15 10:50:05 +01:00
Alejandro Celaya
f6baedc655 Converted CreateServer into functional component 2020-03-15 10:33:23 +01:00
Alejandro Celaya
7db222664d Fixed tests 2020-03-15 09:56:16 +01:00
Alejandro Celaya
8223f0fd64 Undone weird changes in package lock file 2020-03-15 09:43:42 +01:00
Alejandro Celaya
f44ec42f51 Added links to delete and edit the server when a server could not be reached 2020-03-15 09:17:33 +01:00
Alejandro Celaya
dab75ab6a9 Updated badges 2020-03-10 21:53:21 +01:00
Alejandro Celaya
01672b88e1 Merge pull request #222 from acelaya-forks/feature/server-not-found
Feature/server not found
2020-03-08 13:17:56 +01:00
Alejandro Celaya
78dc297022 Updated changelog 2020-03-08 13:05:15 +01:00
Alejandro Celaya
c8cf75fa28 Created ServerError test 2020-03-08 13:04:21 +01:00
Alejandro Celaya
b011b4e1d8 Fixed tests 2020-03-08 12:57:01 +01:00
Alejandro Celaya
9804a2d18d Added list of servers connected to store in ServerError component 2020-03-08 12:50:42 +01:00
Alejandro Celaya
d1a5ee43e9 Created components to display errors when loading a server 2020-03-08 12:41:18 +01:00
Alejandro Celaya
febecab33c Migrated Home component to a functional component 2020-03-08 11:35:06 +01:00
Alejandro Celaya
99042c0979 Extracted servers list group from home component to a reusable component 2020-03-08 11:16:57 +01:00
Alejandro Celaya
6395e4e00b Improved NotFount component so that link text is passed as children 2020-03-08 10:28:04 +01:00
Alejandro Celaya
4a69907ca3 Fixed generation of component keys to make them render properly 2020-03-08 10:16:45 +01:00
Alejandro Celaya
c8d682cc98 Handled loading server in just one place, and added error handling for loading servers 2020-03-08 10:00:25 +01:00
Alejandro Celaya
f4cc8d3a0c Fixed default value for vertically aligned items 2020-03-07 12:07:51 +01:00
Alejandro Celaya
6ac89334fd Merge pull request #220 from acelaya-forks/feature/improvements
Feature/improvements
2020-03-06 21:56:20 +01:00
Alejandro Celaya
f55d3a66aa Converted ShortUrlsRow component into a functional component 2020-03-06 21:44:03 +01:00
Alejandro Celaya
972eafab34 Updated changelog 2020-03-06 21:26:19 +01:00
Alejandro Celaya
fba156b271 Moved copy-to-clipboard control next to short URL 2020-03-06 21:25:30 +01:00
Alejandro Celaya
96d538db15 Replaced Unknown by Direct for traffic comming from undetermined referrers 2020-03-06 20:42:22 +01:00
Alejandro Celaya
b89bfa3c1c Merge pull request #215 from acelaya-forks/feature/versions
Feature/versions
2020-03-05 14:20:31 +01:00
Alejandro Celaya
73e3f42614 Added ShlinkVersions test 2020-03-05 13:55:39 +01:00
Alejandro Celaya
e761f5e1bd Updated changelog 2020-03-05 13:45:24 +01:00
Alejandro Celaya
4a6dd66ecd Added scripts to pass version when building docker image 2020-03-05 13:37:07 +01:00
Alejandro Celaya
8e1c6908c6 Updated build script so that it replaces version placeholder when a version is provided 2020-03-05 13:27:57 +01:00
Alejandro Celaya
f59e569e22 Extracted logic to determine app version from function to generate dist file 2020-03-05 13:04:12 +01:00
Alejandro Celaya
be50b24504 Added mechanism to provide a version to shlink-web-client 2020-03-05 12:53:32 +01:00
Alejandro Celaya
c181831a37 Fixed tests 2020-03-05 11:58:35 +01:00
Alejandro Celaya
dbee62ac8c Moved shlink versions component to main container 2020-03-05 11:46:38 +01:00
Alejandro Celaya
1e949b3a22 Added shlink versions to side menu 2020-03-05 11:11:26 +01:00
Alejandro Celaya
b02dcf6c53 Refactored delete server components 2020-03-05 10:18:38 +01:00
Alejandro Celaya
ab7718e335 Removed duplicated code from AsideMenu by creating an AsideMenuItem helper component 2020-03-05 10:03:38 +01:00
Alejandro Celaya
451c77d47f Merge pull request #214 from acelaya-forks/feature/consistent-server-loading
Feature/consistent server loading
2020-03-05 09:32:59 +01:00
Alejandro Celaya
fa0d3d4047 Removed no longer needed async/await when building api client 2020-03-05 09:23:53 +01:00
Alejandro Celaya
397a183f65 Converted MenuLayout into a functional component with hooks 2020-03-05 09:08:50 +01:00
Alejandro Celaya
bc8905ee7f Ensured server is properly loaded before trying to render any children component 2020-03-05 08:59:07 +01:00
Alejandro Celaya
853032ac7f Displayed preloader when a server is being loaded 2020-03-05 08:41:55 +01:00
Alejandro Celaya
3b0e282a52 Merge pull request #211 from acelaya-forks/feature/jest-each
Feature/jest each
2020-02-17 18:32:52 +01:00
Alejandro Celaya
bb28cb3862 Updated changelog 2020-02-17 18:25:21 +01:00
Alejandro Celaya
d0f458bece Uninstalled jest-each and replaced by jest's native each 2020-02-17 18:21:52 +01:00
217 changed files with 7598 additions and 3725 deletions

View File

@@ -1,6 +1,7 @@
./.github
./build
./coverage
./dist
./node_modules
./test
./shlink-web-client.gif
./dist

View File

@@ -29,6 +29,7 @@
"no-magic-numbers": "off",
"no-undefined": "off",
"no-inline-comments": "off",
"lines-around-comment": "off",
"indent": ["error", 2, {
"SwitchCase": 1
}

View File

@@ -1,7 +1,21 @@
dist: bionic
language: node_js
node_js:
- "12.14.1"
jobs:
fast_finish: true
include:
- name: "Docker publish"
node_js: '12.16.3'
if: NOT type = pull_request
env:
- DOCKER_PUBLISH="true"
- name: "CI"
node_js: '12.16.3'
env:
- DOCKER_PUBLISH="false"
allow_failures:
- name: "Docker publish"
cache:
directories:
@@ -11,30 +25,34 @@ services:
- docker
install:
- npm ci
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then sudo bash ./scripts/docker/install-docker ; fi
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm ci ; fi
before_script:
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then echo "Building commit range ${TRAVIS_COMMIT_RANGE}" ; fi
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",") ; fi
script:
- npm run lint
- npm run test:ci
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then bash ./scripts/docker/build ; fi
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run lint ; fi
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run test:ci ; fi
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then docker build -t shlink-web-client:test . ; fi
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run mutate:ci ; fi
after_success:
- node_modules/.bin/ocular coverage/clover.xml
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then node_modules/.bin/ocular coverage/clover.xml ; fi
# Before deploying, build dist file for current travis tag
before_deploy:
- npm run build ${TRAVIS_TAG#?}
- if [[ ! -z $TRAVIS_TAG && ${DOCKER_PUBLISH} == 'false' ]]; then npm run build ${TRAVIS_TAG#?} ; fi
deploy:
provider: releases
api_key:
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
- provider: releases
api_key:
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
all_branches: true
condition: ${DOCKER_PUBLISH} == 'false'
tags: true

View File

@@ -4,6 +4,111 @@ 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).
## 2.5.1 - 2020-06-06
#### Added
* *Nothing*
#### Changed
* [#254](https://github.com/shlinkio/shlink-web-client/issues/254) Reduced duplication on code to handle mercure topics binding.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#276](https://github.com/shlinkio/shlink-web-client/issues/276) Fixed default grouping used for visits line chart, making it be dynamic depending on how old the short URL is.
* [#280](https://github.com/shlinkio/shlink-web-client/issues/280) Fixed shlink-web-client version not being properly passed when building stable tags of the docker image.
* [#269](https://github.com/shlinkio/shlink-web-client/issues/269) Fixed doughnut chart legends getting to big and hiding charts on mobile devices.
## 2.5.0 - 2020-05-31
#### Added
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
* [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag.
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag.
This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag.
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
#### Changed
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
* [#255](https://github.com/shlinkio/shlink-web-client/issues/255) Improved how servers and settings are persisted in the local storage.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#262](https://github.com/shlinkio/shlink-web-client/issues/262) Fixed charts displaying decimal numbers, when visits are absolute and that makes no sense.
## 2.4.0 - 2020-04-10
#### Added
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
* [#241](https://github.com/shlinkio/shlink-web-client/issues/241) Added support to select charts bars in order to highlight related stats in other charts.
It also selects the visits in the new table, and you can even combine a selection in the chart and in the table.
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
#### Changed
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
## 2.3.1 - 2020-02-08
#### Added

View File

@@ -1,8 +1,11 @@
FROM node:12.14.1-alpine as node
FROM node:12.16.3-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && npm install && npm run build
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.17.7-alpine
FROM nginx:1.17.10-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

View File

@@ -1,15 +1,17 @@
# shlink-web-client
[![Build Status](https://img.shields.io/travis/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink-web-client)
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://acel.me/donate)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
![shlink-web-client](shlink-web-client.gif)
## Installation
There are three ways in which you can use this application.

View File

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

2337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{
"name": "shlink-web-client",
"description": "A React-based progressive web application for shlink",
"version": "2.3.0",
"private": false,
"homepage": "",
"repository": "https://github.com/shlinkio/shlink-web-client",
@@ -19,7 +18,8 @@
"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",
"mutate": "./node_modules/.bin/stryker run",
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES",
"check": "npm run test & npm run lint & wait"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.11.2",
@@ -33,23 +33,25 @@
"axios": "^0.19.0",
"bootstrap": "^4.3.1",
"bottlejs": "^1.7.2",
"bowser": "^2.9.0",
"chart.js": "^2.8.0",
"classnames": "^2.2.6",
"compare-versions": "^3.5.1",
"csvjson": "^5.1.0",
"event-source-polyfill": "^1.0.12",
"leaflet": "^1.5.1",
"moment": "^2.24.0",
"promise": "^8.0.3",
"prop-types": "^15.7.2",
"qs": "^6.9.0",
"ramda": "^0.26.1",
"react": "^16.10.2",
"react": "^16.13.1",
"react-autosuggest": "^9.4.3",
"react-chartjs-2": "^2.8.0",
"react-color": "^2.17.3",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "~1.5.0",
"react-dom": "^16.10.2",
"react-dom": "^16.13.1",
"react-external-link": "^1.0.0",
"react-leaflet": "^2.4.0",
"react-moment": "^0.9.5",
@@ -60,15 +62,15 @@
"reactstrap": "^8.0.1",
"redux": "^4.0.4",
"redux-actions": "^2.6.5",
"redux-localstorage-simple": "^2.2.0",
"redux-thunk": "^2.3.0",
"uuid": "^3.3.3"
},
"devDependencies": {
"@babel/core": "^7.6.2",
"@stryker-mutator/core": "^2.1.0",
"@stryker-mutator/html-reporter": "^2.1.0",
"@stryker-mutator/javascript-mutator": "^2.1.0",
"@stryker-mutator/jest-runner": "^2.1.0",
"@stryker-mutator/core": "^3.2.4",
"@stryker-mutator/javascript-mutator": "^3.2.4",
"@stryker-mutator/jest-runner": "^3.2.4",
"@svgr/webpack": "^4.3.3",
"adm-zip": "^0.4.13",
"autoprefixer": "^9.6.3",
@@ -85,8 +87,8 @@
"css-loader": "^3.2.0",
"dotenv": "^8.1.0",
"dotenv-expand": "^5.1.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^5.11.1",
"eslint-config-adidas-babel": "^1.1.0",
"eslint-config-adidas-env": "^1.1.0",
@@ -104,7 +106,6 @@
"html-webpack-plugin": "^4.0.0-beta.8",
"identity-obj-proxy": "^3.0.0",
"jest": "^24.9.0",
"jest-each": "^24.9.0",
"jest-pnp-resolver": "^1.2.1",
"jest-resolve": "^24.9.0",
"mini-css-extract-plugin": "^0.8.0",

View File

@@ -14,7 +14,6 @@ process.on('unhandledRejection', (err) => {
// Ensure environment variables are read.
require('../config/env');
const path = require('path');
const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
@@ -22,7 +21,6 @@ const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
@@ -30,7 +28,6 @@ const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
@@ -47,6 +44,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.indexOf('--stats') !== -1;
const withoutDist = argv.indexOf('--no-dist') !== -1;
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration
const config = configFactory('production');
@@ -85,6 +84,7 @@ checkBrowsers(paths.appPath, isInteractive)
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
}
console.log('File sizes after gzip:\n');
@@ -96,20 +96,6 @@ checkBrowsers(paths.appPath, isInteractive)
WARN_AFTER_CHUNK_GZIP_SIZE
);
console.log();
const appPackage = require(paths.appPackageJson);
const { publicUrl } = paths;
const { output: { publicPath } } = config;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
publicUrl,
publicPath,
buildFolder,
useYarn
);
},
(err) => {
console.log(chalk.red('Failed to compile.\n'));
@@ -117,7 +103,7 @@ checkBrowsers(paths.appPath, isInteractive)
process.exit(1);
}
)
.then(zipDist)
.then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => {
if (err && err.message) {
console.log(err.message);
@@ -200,15 +186,7 @@ function copyPublicFolder() {
});
}
function zipDist() {
const minArgsToContainVersion = 3;
// If no version was provided, do nothing
if (process.argv.length < minArgsToContainVersion) {
return;
}
const [ , , version ] = process.argv;
function zipDist(version) {
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
@@ -226,4 +204,24 @@ function zipDist() {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
console.log();
}
function getVersionFromArgs(argv) {
const [ version ] = argv;
return { version, hasVersion: !!version };
}
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
}

33
scripts/docker/build Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -e
#PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
PLATFORMS="linux/amd64"
DOCKER_IMAGE="shlinkio/shlink-web-client"
BUILDX_VER=v0.4.1
export DOCKER_CLI_EXPERIMENTAL=enabled
mkdir -vp ~/.docker/cli-plugins/ ~/dockercache
curl --silent -L "https://github.com/docker/buildx/releases/download/${BUILDX_VER}/buildx-${BUILDX_VER}.linux-amd64" > ~/.docker/cli-plugins/docker-buildx
chmod a+x ~/.docker/cli-plugins/docker-buildx
docker buildx create --use
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
if [[ -z $TRAVIS_TAG ]]; then
docker buildx build --push \
--platform ${PLATFORMS} \
-t ${DOCKER_IMAGE}:latest .
else
TAGS="-t ${DOCKER_IMAGE}:${TRAVIS_TAG#?}"
# Push stable tag only if this is not an alpha or beta release
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
docker buildx build --push \
--build-arg VERSION=${TRAVIS_TAG#?} \
--platform ${PLATFORMS} \
${TAGS} .
fi

12
scripts/docker/install-docker Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -ex
# install latest docker version
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
apt-get update
apt-get -y -o Dpkg::Options::="--force-confnew" install docker-ce
# enable multiarch execution
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

BIN
shlink-web-client.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -1,21 +1,40 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Route, Switch } from 'react-router-dom';
import './App.scss';
import NotFound from './common/NotFound';
import './App.scss';
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
<div className="container-fluid app-container">
<MainHeader />
const propTypes = {
fetchServers: PropTypes.func,
servers: PropTypes.object,
};
<div className="app">
<Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer, Settings) => ({ fetchServers, servers }) => {
// On first load, try to fetch the remote servers if the list is empty
useEffect(() => {
if (Object.keys(servers).length === 0) {
fetchServers();
}
}, []);
return (
<div className="container-fluid app-container">
<MainHeader />
<div className="app">
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
</div>
</div>
</div>
);
);
};
App.propTypes = propTypes;
export default App;

View File

@@ -1,16 +1,34 @@
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import {
faList as listIcon,
faLink as createIcon,
faTags as tagsIcon,
faPen as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import classNames from 'classnames';
import { serverType } from '../servers/prop-types';
import './AsideMenu.scss';
const defaultProps = {
className: '',
showOnMobile: false,
const AsideMenuItem = ({ children, to, className, ...rest }) => (
<NavLink
className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected"
to={to}
{...rest}
>
{children}
</NavLink>
);
AsideMenuItem.propTypes = {
children: PropTypes.node.isRequired,
to: PropTypes.string.isRequired,
className: PropTypes.string,
};
const propTypes = {
selectedServer: serverType,
className: PropTypes.string,
@@ -20,43 +38,34 @@ const propTypes = {
const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
const asideClass = classNames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/create-short-url')}>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/manage-tags')}>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon 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}
/>
</nav>
@@ -64,7 +73,6 @@ const AsideMenu = (DeleteServerButton) => {
);
};
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
return AsideMenu;

View File

@@ -67,6 +67,9 @@ $asideMenuMobileWidth: 280px;
.aside-menu__item--danger {
color: $dangerColor;
}
.aside-menu__item--push {
margin-top: auto;
}

View File

@@ -1,52 +1,34 @@
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useEffect } from 'react';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import PropTypes from 'prop-types';
import './Home.scss';
import ServersListGroup from '../servers/ServersListGroup';
export default class Home extends React.Component {
static propTypes = {
resetSelectedServer: PropTypes.func,
servers: PropTypes.object,
};
const propTypes = {
resetSelectedServer: PropTypes.func,
servers: PropTypes.object,
};
componentDidMount() {
this.props.resetSelectedServer();
}
const Home = ({ resetSelectedServer, servers }) => {
const serversList = values(servers);
const hasServers = !isEmpty(serversList);
render() {
const { servers: { list, loading } } = this.props;
const servers = values(list);
const hasServers = !isEmpty(servers);
useEffect(() => {
resetSelectedServer();
}, []);
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<h5 className="home__intro">
{!loading && hasServers && <span>Please, select a server.</span>}
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{loading && <span>Trying to load servers...</span>}
</h5>
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<ServersListGroup servers={serversList}>
{hasServers && <span>Please, select a server.</span>}
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
</ServersListGroup>
</div>
);
};
{!loading && hasServers && (
<ListGroup className="home__servers-list">
{servers.map(({ name, id }) => (
<ListGroupItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
className="home__servers-item"
>
{name}
<FontAwesomeIcon icon={chevronIcon} className="home__servers-item-icon" />
</ListGroupItem>
))}
</ListGroup>
)}
</div>
);
}
}
Home.propTypes = propTypes;
export default Home;

View File

@@ -1,5 +1,4 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
.home {
text-align: center;
@@ -17,21 +16,3 @@
font-size: 2.2rem;
}
}
.home__servers-list {
margin-top: 1rem;
width: 100%;
max-width: 400px;
}
.home__servers-item.home__servers-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.home__servers-item-icon {
@include vertical-align();
right: 1rem;
}

View File

@@ -1,37 +1,28 @@
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import classnames from 'classnames';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { useToggle } from '../utils/helpers/hooks';
import shlinkLogo from './shlink-logo-white.png';
import './MainHeader.scss';
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
static propTypes = {
location: PropTypes.object,
};
const propTypes = {
location: PropTypes.object,
};
state = { isOpen: false };
handleToggle = () => {
this.setState(({ isOpen }) => ({
isOpen: !isOpen,
}));
};
const MainHeader = (ServersDropdown) => {
const MainHeaderComp = ({ location }) => {
const [ isOpen, toggleOpen, , close ] = useToggle();
const { pathname } = location;
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.setState({ isOpen: false });
}
}
useEffect(close, [ location ]);
render() {
const { location } = this.props;
const createServerPath = '/server/create';
const toggleClass = classnames('main-header__toggle-icon', {
'main-header__toggle-icon--opened': this.state.isOpen,
});
const settingsPath = '/settings';
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
@@ -39,18 +30,19 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={this.handleToggle}>
<NavbarToggler onClick={toggleOpen}>
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
</NavbarToggler>
<Collapse navbar isOpen={this.state.isOpen}>
<Collapse navbar isOpen={isOpen}>
<Nav navbar className="ml-auto">
<NavItem>
<NavLink
tag={Link}
to={createServerPath}
active={location.pathname === createServerPath}
>
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
<FontAwesomeIcon icon={plusIcon} />&nbsp; Add server
</NavLink>
</NavItem>
@@ -59,7 +51,11 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
</Collapse>
</Navbar>
);
}
};
MainHeaderComp.propTypes = propTypes;
return MainHeaderComp;
};
export default MainHeader;

View File

@@ -1,110 +1,98 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import classNames from 'classnames';
import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useToggle } from '../utils/helpers/hooks';
import { versionMatch } from '../utils/helpers/version';
import NotFound from './NotFound';
import './MenuLayout.scss';
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
class MenuLayout extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
const propTypes = {
match: PropTypes.object,
location: PropTypes.object,
selectedServer: serverType,
};
const MenuLayout = (
TagsList,
ShortUrls,
AsideMenu,
CreateShortUrl,
ShortUrlVisits,
TagVisits,
ShlinkVersions,
ServerError
) => {
const MenuLayoutComp = ({ match, location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
const { params: { serverId } } = match;
useEffect(() => hideSidebar(), [ location ]);
if (selectedServer.serverNotReachable) {
return <ServerError type="not-reachable" />;
}
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible,
});
const swipeMenuIfNoModalExists = (callback) => (e) => {
const swippedOnVisitsTable = e.event.path.some(
({ classList }) => classList && classList.contains('visits-table')
);
if (swippedOnVisitsTable || document.querySelector('.modal')) {
return;
}
callback();
};
state = { showSideBar: false };
return (
<React.Fragment>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
componentDidMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer, match } = this.props;
const { params: { serverId } } = match;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
if (document.querySelector('.modal')) {
return;
}
this.setState({ showSideBar });
};
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Switch>
</div>
<div className="menu-layout__footer text-center text-md-right">
<ShlinkVersions />
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
</div>
</Swipeable>
</React.Fragment>
);
};
MenuLayoutComp.propTypes = propTypes;
return withSelectedServer(MenuLayoutComp, ServerError);
};
export default MenuLayout;

View File

@@ -32,3 +32,26 @@
.menu-layout__burger-icon--active {
color: white;
}
$footer-height: 2.3rem;
$footer-margin: .8rem;
.menu-layout__container {
padding: 20px 0 ($footer-height + $footer-margin);
min-height: 100%;
margin-bottom: -($footer-height + $footer-margin);
@media (min-width: $mdMin) {
padding: 30px 15px ($footer-height + $footer-margin);
}
}
.menu-layout__footer {
height: $footer-height;
margin-top: $footer-margin;
padding: 0;
@media (min-width: $mdMin) {
padding: 0 15px;
}
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import './NoMenuLayout.scss';
const propTypes = {
children: PropTypes.node,
};
const NoMenuLayout = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
NoMenuLayout.propTypes = propTypes;
export default NoMenuLayout;

View File

@@ -0,0 +1,3 @@
.no-menu-wrapper {
padding: 40px 20px;
}

View File

@@ -4,17 +4,18 @@ import * as PropTypes from 'prop-types';
const propTypes = {
to: PropTypes.string,
btnText: PropTypes.string,
children: PropTypes.node,
};
const NotFound = ({ to = '/', btnText = 'Home' }) => (
const NotFound = ({ to = '/', children = 'Home' }) => (
<div className="home">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button.
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
button.
</p>
<br />
<Link to={to} className="btn btn-outline-primary btn-lg">{btnText}</Link>
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
</div>
);

View File

@@ -1,23 +1,23 @@
import React from 'react';
import { useEffect } from 'react';
import PropTypes from 'prop-types';
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
children: PropTypes.node,
const propTypes = {
location: PropTypes.object,
children: PropTypes.node,
};
const ScrollToTop = () => {
const ScrollToTopComp = ({ location, children }) => {
useEffect(() => {
scrollTo(0, 0);
}, [ location ]);
return children;
};
componentDidUpdate({ location: prevLocation }) {
const { location } = this.props;
ScrollToTopComp.propTypes = propTypes;
if (location !== prevLocation) {
scrollTo(0, 0);
}
}
render() {
return this.props.children;
}
return ScrollToTopComp;
};
export default ScrollToTop;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { pipe } from 'ramda';
import { serverType } from '../servers/prop-types';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const propTypes = {
selectedServer: serverType,
className: PropTypes.string,
clientVersion: PropTypes.string,
};
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
const { printableVersion: serverVersion } = selectedServer;
const normalizedClientVersion = pipe(versionToSemVer(), versionToPrintable)(clientVersion);
return (
<small className={classNames('text-muted', className)}>
Client: <b>{normalizedClientVersion}</b> - Server: <b>{serverVersion}</b>
</small>
);
};
ShlinkVersions.propTypes = propTypes;
export default ShlinkVersions;

View File

@@ -1,38 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { range, max, min } from 'ramda';
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
import './SimplePaginator.scss';
const propTypes = {
pagesCount: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
setCurrentPage: PropTypes.func.isRequired,
centered: PropTypes.bool,
};
export const ellipsis = '...';
const pagination = (currentPage, pageCount) => {
const delta = 2;
const pages = range(
max(delta, currentPage - delta),
min(pageCount - 1, currentPage + delta) + 1
);
if (currentPage - delta > delta) {
pages.unshift(ellipsis);
}
if (currentPage + delta < pageCount - 1) {
pages.push(ellipsis);
}
pages.unshift(1);
pages.push(pageCount);
return pages;
};
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
if (pagesCount < 2) {
return null;
}
@@ -40,17 +20,17 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
const onClick = (page) => () => setCurrentPage(page);
return (
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
<PaginationItem disabled={currentPage <= 1}>
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
</PaginationItem>
{pagination(currentPage, pagesCount).map((page, index) => (
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
<PaginationItem
key={page !== ellipsis ? page : `${page}_${index}`}
active={page === currentPage}
disabled={page === ellipsis}
key={keyForPage(pageNumber, index)}
disabled={isPageDisabled(pageNumber)}
active={currentPage === pageNumber}
>
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink>
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>

View File

@@ -4,6 +4,7 @@ import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
import ShlinkVersions from '../ShlinkVersions';
const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window);
@@ -25,13 +26,19 @@ const provideServices = (bottle, connect, withRouter) => {
'ShortUrls',
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits'
'ShortUrlVisits',
'TagVisits',
'ShlinkVersions',
'ServerError'
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};

View File

@@ -9,6 +9,8 @@ import provideServersServices from '../servers/services/provideServices';
import provideVisitsServices from '../visits/services/provideServices';
import provideTagsServices from '../tags/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices';
import provideMercureServices from '../mercure/services/provideServices';
import provideSettingsServices from '../settings/services/provideServices';
const bottle = new Bottle();
const { container } = bottle;
@@ -26,7 +28,8 @@ const connect = (propsFromState, actionServiceNames = []) =>
actionServiceNames.reduce(mapActionService, {})
);
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings');
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
provideCommonServices(bottle, connect, withRouter);
provideShortUrlsServices(bottle, connect);
@@ -34,5 +37,7 @@ provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);
provideMercureServices(bottle);
provideSettingsServices(bottle, connect);
export default container;

View File

@@ -1,13 +1,21 @@
import ReduxThunk from 'redux-thunk';
import { applyMiddleware, compose, createStore } from 'redux';
import { save, load } from 'redux-localstorage-simple';
import reducers from '../reducers';
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
: compose;
const store = createStore(reducers, composeEnhancers(
applyMiddleware(ReduxThunk)
const localStorageConfig = {
states: [ 'settings', 'servers' ],
namespace: 'shlink',
namespaceSeparator: '.',
debounce: 300,
};
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
applyMiddleware(save(localStorageConfig), ReduxThunk)
));
export default store;

View File

@@ -10,10 +10,6 @@ body,
outline: none !important;
}
.nowrap {
white-space: nowrap;
}
.bg-main {
background-color: $mainColor !important;
}
@@ -28,14 +24,6 @@ body,
color: inherit !important;
}
.shlink-container {
padding: 20px 0;
@media (min-width: $mdMin) {
padding: 30px 15px;
}
}
.badge-main {
color: #fff;
background-color: $mainColor;
@@ -56,11 +44,13 @@ body,
cursor: pointer;
}
.paddingless {
padding: 0;
.indivisible {
white-space: nowrap;
}
.indivisible {
.text-ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
@@ -71,3 +61,7 @@ body,
background-color: darken($mainColor, 12%);
}
}
.progress-bar {
background-color: $mainColor;
}

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
export const bindToMercureTopic = (mercureInfo, topic, onMessage, onTokenExpired) => () => {
const { mercureHubUrl, token, loading, error } = mercureInfo;
if (loading || error) {
return undefined;
}
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', topic);
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
es.onmessage = ({ data }) => onMessage(JSON.parse(data));
es.onerror = ({ status }) => status === 401 && onTokenExpired();
return () => es.close();
};
export const useMercureTopicBinding = (mercureInfo, topic, onMessage, onTokenExpired) => {
useEffect(bindToMercureTopic(mercureInfo, topic, onMessage, onTokenExpired), [ mercureInfo ]);
};

View File

@@ -0,0 +1,49 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
/* eslint-enable padding-line-between-statements */
export const MercureInfoType = PropTypes.shape({
token: PropTypes.string,
mercureHubUrl: PropTypes.string,
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = {
token: undefined,
mercureHubUrl: undefined,
loading: true,
error: false,
};
export default handleActions({
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[GET_MERCURE_INFO]: (state, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }),
}, initialState);
export const loadMercureInfo = (buildShlinkApiClient) => () => async (dispatch, getState) => {
dispatch({ type: GET_MERCURE_INFO_START });
const { settings } = getState();
const { mercureInfo } = buildShlinkApiClient(getState);
if (!settings.realTimeUpdates.enabled) {
dispatch({ type: GET_MERCURE_INFO_ERROR });
return;
}
try {
const result = await mercureInfo();
dispatch({ type: GET_MERCURE_INFO, ...result });
} catch (e) {
dispatch({ type: GET_MERCURE_INFO_ERROR });
}
};

View File

@@ -0,0 +1,8 @@
import { loadMercureInfo } from '../reducers/mercureInfo';
const provideServices = (bottle) => {
// Actions
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
};
export default provideServices;

View File

@@ -1,5 +1,5 @@
import { combineReducers } from 'redux';
import serversReducer from '../servers/reducers/server';
import serversReducer from '../servers/reducers/servers';
import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
@@ -7,11 +7,15 @@ import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit';
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
export default combineReducers({
servers: serversReducer,
@@ -22,9 +26,13 @@ export default combineReducers({
shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer,
shortUrlMeta: shortUrlMetaReducer,
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
tagEdit: tagEditReducer,
mercureInfo: mercureInfoReducer,
settings: settingsReducer,
});

View File

@@ -1,91 +1,57 @@
import { assoc, dissoc, pipe } from 'ramda';
import React from 'react';
import React, { useEffect } from 'react';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import NoMenuLayout from '../common/NoMenuLayout';
import { ServerForm } from './helpers/ServerForm';
import './CreateServer.scss';
const SHOW_IMPORT_MSG_TIME = 4000;
const propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
resetSelectedServer: PropTypes.func,
};
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
static propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
resetSelectedServer: PropTypes.func,
};
const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const handleSubmit = (serverData) => {
const id = uuid();
const server = { id, ...serverData };
state = {
name: '',
url: '',
apiKey: '',
serversImported: false,
};
createServer(server);
push(`/server/${id}/list-short-urls/1`);
};
handleSubmit = (e) => {
e.preventDefault();
const { createServer, history: { push } } = this.props;
const server = pipe(
assoc('id', uuid()),
dissoc('serversImported')
)(this.state);
createServer(server);
push(`/server/${server.id}/list-short-urls/1`);
};
componentDidMount() {
this.props.resetSelectedServer();
}
render() {
const renderInputGroup = (id, placeholder, type = 'text') => (
<div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
{placeholder}:
</label>
<div className="col-lg-11 col-md-10">
<input
type={type}
className="form-control"
id={id}
placeholder={placeholder}
value={this.state[id]}
required
onChange={(e) => this.setState({ [id]: e.target.value })}
/>
</div>
</div>
);
useEffect(() => {
resetSelectedServer();
}, []);
return (
<div className="create-server">
<form onSubmit={this.handleSubmit}>
{renderInputGroup('name', 'Name')}
{renderInputGroup('url', 'URL', 'url')}
{renderInputGroup('apiKey', 'API key')}
<NoMenuLayout>
<ServerForm onSubmit={handleSubmit}>
<ImportServersBtn onImport={setServersImported} />
<button className="btn btn-outline-primary">Create server</button>
</ServerForm>
<div className="text-right">
<ImportServersBtn
onImport={() => stateFlagTimeout(this.setState.bind(this), 'serversImported', true, SHOW_IMPORT_MSG_TIME)}
/>
<button className="btn btn-outline-primary">Create server</button>
</div>
{this.state.serversImported && (
<div className="row create-server__import-success-msg">
<div className="col-md-10 offset-md-1">
<div className="p-2 mt-3 bg-main text-white text-center">
Servers properly imported. You can now select one from the list :)
</div>
{serversImported && (
<div className="row create-server__import-success-msg">
<div className="col-md-10 offset-md-1">
<div className="p-2 mt-3 bg-main text-white text-center">
Servers properly imported. You can now select one from the list :)
</div>
</div>
)}
</form>
</div>
</div>
)}
</NoMenuLayout>
);
}
};
CreateServerComp.propTypes = propTypes;
return CreateServerComp;
};
export default CreateServer;

View File

@@ -1,9 +1,5 @@
@import '../utils/base';
.create-server {
padding: 40px 20px;
}
.create-server__label {
font-weight: 700;
cursor: pointer;

View File

@@ -1,40 +1,36 @@
import React from 'react';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types';
import { useToggle } from '../utils/helpers/hooks';
import { serverType } from './prop-types';
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
static propTypes = {
server: serverType,
className: PropTypes.string,
};
const propTypes = {
server: serverType,
className: PropTypes.string,
textClassName: PropTypes.string,
children: PropTypes.node,
};
state = { isModalOpen: false };
render() {
const { server, className } = this.props;
const DeleteServerButton = (DeleteServerModal) => {
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
const [ isModalOpen, , showModal, hideModal ] = useToggle();
return (
<React.Fragment>
<span
className={className}
key="deleteServerBtn"
onClick={() => this.setState({ isModalOpen: true })}
>
<FontAwesomeIcon icon={deleteIcon} />
<span className="aside-menu__item-text">Delete this server</span>
<span className={className} onClick={showModal}>
{!children && <FontAwesomeIcon icon={deleteIcon} />}
<span className={textClassName}>{children || 'Remove this server'}</span>
</span>
<DeleteServerModal
isOpen={this.state.isModalOpen}
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
server={server}
key="deleteServerModal"
/>
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
</React.Fragment>
);
}
};
DeleteServerButtonComp.propTypes = propTypes;
return DeleteServerButtonComp;
};
export default DeleteServerButton;

View File

@@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
<ModalHeader toggle={toggle}><span className="text-danger">Remove server</span></ModalHeader>
<ModalBody>
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
No data will be deleted, only the access to that server will be removed from this host.
You can create it again at any moment.
<i>
No data will be deleted, only the access to this server will be removed from this host.
You can create it again at any moment.
</i>
</p>
</ModalBody>
<ModalFooter>

38
src/servers/EditServer.js Normal file
View File

@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { serverType } from './prop-types';
const propTypes = {
editServer: PropTypes.func,
selectedServer: serverType,
history: PropTypes.shape({
push: PropTypes.func,
goBack: PropTypes.func,
}),
};
export const EditServer = (ServerError) => {
const EditServerComp = ({ editServer, selectedServer, history: { push, goBack } }) => {
const handleSubmit = (serverData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}/list-short-urls/1`);
};
return (
<NoMenuLayout>
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
<Button outline color="primary">Save</Button>
</ServerForm>
</NoMenuLayout>
);
};
EditServerComp.propTypes = propTypes;
return withSelectedServer(EditServerComp, ServerError);
};

View File

@@ -2,63 +2,54 @@ import { isEmpty, values } from 'ramda';
import React from 'react';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { serverType } from './prop-types';
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
static propTypes = {
servers: PropTypes.object,
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
const propTypes = {
servers: PropTypes.object,
selectedServer: serverType,
};
renderServers = () => {
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
const servers = values(list);
const { push } = this.props.history;
const loadServer = (id) => {
selectServer(id)
.then(() => push(`/server/${id}/list-short-urls/1`))
.catch(() => {});
const ServersDropdown = (serversExporter) => {
const ServersDropdownComp = ({ servers, selectedServer }) => {
const serversList = values(servers);
const renderServers = () => {
if (isEmpty(serversList)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
}
return (
<React.Fragment>
{serversList.map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
active={selectedServer && selectedServer.id === id}
>
{name}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
Export servers
</DropdownItem>
</React.Fragment>
);
};
if (loading) {
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
}
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
}
return (
<React.Fragment>
{servers.map(({ name, id }) => (
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
{name}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem
className="servers-dropdown__export-item"
onClick={() => serversExporter.exportServers()}
>
Export servers
</DropdownItem>
</React.Fragment>
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};
componentDidMount = this.props.listServers;
ServersDropdownComp.propTypes = propTypes;
render = () => (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
return ServersDropdownComp;
};
export default ServersDropdown;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ListGroup, ListGroupItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { serverType } from './prop-types';
import './ServersListGroup.scss';
const propTypes = {
servers: PropTypes.arrayOf(serverType).isRequired,
children: PropTypes.node.isRequired,
};
const ServerListItem = ({ id, name }) => (
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
{name}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
</ListGroupItem>
);
ServerListItem.propTypes = {
id: PropTypes.string,
name: PropTypes.string,
};
const ServersListGroup = ({ servers, children }) => (
<React.Fragment>
<div className="container">
<h5>{children}</h5>
</div>
{servers.length > 0 && (
<ListGroup className="servers-list__list-group mt-md-3">
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</ListGroup>
)}
</React.Fragment>
);
ServersListGroup.propTypes = propTypes;
export default ServersListGroup;

View File

@@ -0,0 +1,18 @@
@import '../utils/mixins/vertical-align';
.servers-list__list-group {
width: 100%;
max-width: 400px;
}
.servers-list__server-item.servers-list__server-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item-icon {
@include vertical-align();
right: 1rem;
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compareVersions } from '../../utils/utils';
import { serverType } from '../prop-types';
import { versionMatch } from '../../utils/helpers/version';
const propTypes = {
minVersion: PropTypes.string,
@@ -16,10 +16,9 @@ const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children })
}
const { version } = selectedServer;
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion);
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
if (!matchesMinVersion || !matchesMaxVersion) {
if (!matchesVersion) {
return null;
}

View File

@@ -1,25 +1,17 @@
import React from 'react';
import React, { useRef } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import PropTypes from 'prop-types';
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
static defaultProps = {
onImport: () => ({}),
};
static propTypes = {
onImport: PropTypes.func,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
const propTypes = {
onImport: PropTypes.func,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
constructor(props) {
super(props);
this.fileRef = props.fileRef || React.createRef();
}
render() {
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
// FIXME Replace with typescript: (ServersImporter)
const ImportServersBtn = ({ importServersFromFile }) => {
const ImportServersBtnComp = ({ createServers, fileRef, onImport = () => {} }) => {
const ref = fileRef || useRef();
const onChange = ({ target }) =>
importServersFromFile(target.files[0])
.then(createServers)
@@ -35,24 +27,22 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
type="button"
className="btn btn-outline-secondary mr-2"
id="importBtn"
onClick={() => this.fileRef.current.click()}
onClick={() => ref.current.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip>
<input
type="file"
accept="text/csv"
className="create-server__csv-select"
ref={this.fileRef}
onChange={onChange}
/>
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
</React.Fragment>
);
}
};
ImportServersBtnComp.propTypes = propTypes;
return ImportServersBtnComp;
};
export default ImportServersBtn;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { serverType } from '../prop-types';
import './ServerError.scss';
const propTypes = {
servers: PropTypes.object,
selectedServer: serverType,
type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired,
};
export const ServerError = (DeleteServerButton) => {
const ServerErrorComp = ({ type, servers, selectedServer }) => (
<div className="server-error__container flex-column">
<div className="row w-100 mb-3 mb-md-5">
<Message type="error">
{type === 'not-found' && 'Could not find this Shlink server.'}
{type === 'not-reachable' && (
<React.Fragment>
<p>Oops! Could not connect to this Shlink server.</p>
Make sure you have internet connection, and the server is properly configured and on-line.
</React.Fragment>
)}
</Message>
</div>
<ServersListGroup servers={Object.values(servers)}>
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup>
{type === 'not-reachable' && (
<div className="container mt-3 mt-md-5">
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
</h5>
</div>
)}
</div>
);
ServerErrorComp.propTypes = propTypes;
return ServerErrorComp;
};

View File

@@ -0,0 +1,17 @@
@import '../../utils/base';
.server-error__container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.server-error__delete-btn {
color: $dangerColor;
cursor: pointer;
}
.server-error__delete-btn:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
const propTypes = {
onSubmit: PropTypes.func.isRequired,
initialValues: PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
apiKey: PropTypes.string.isRequired,
}),
children: PropTypes.node.isRequired,
};
export const ServerForm = ({ onSubmit, initialValues, children }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
const [ apiKey, setApiKey ] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ name, url, apiKey });
};
useEffect(() => {
initialValues && setName(initialValues.name);
initialValues && setUrl(initialValues.url);
initialValues && setApiKey(initialValues.apiKey);
}, [ initialValues ]);
return (
<form onSubmit={handleSubmit}>
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
<div className="text-right">{children}</div>
</form>
);
};
ServerForm.propTypes = propTypes;

View File

@@ -0,0 +1,35 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import Message from '../../utils/Message';
import { serverType } from '../prop-types';
const propTypes = {
selectServer: PropTypes.func,
selectedServer: serverType,
match: PropTypes.object,
};
export const withSelectedServer = (WrappedComponent, ServerError) => {
const Component = (props) => {
const { selectServer, selectedServer, match } = props;
const { params: { serverId } } = match;
useEffect(() => {
selectServer(serverId);
}, [ serverId ]);
if (!selectedServer) {
return <Message loading />;
}
if (selectedServer.serverNotFound) {
return <ServerError type="not-found" />;
}
return <WrappedComponent {...props} />;
};
Component.propTypes = propTypes;
return Component;
};

View File

@@ -1,9 +1,20 @@
import PropTypes from 'prop-types';
export const serverType = PropTypes.shape({
const regularServerType = PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
version: PropTypes.string,
printableVersion: PropTypes.string,
serverNotReachable: PropTypes.bool,
});
const notFoundServerType = PropTypes.shape({
serverNotFound: PropTypes.bool.isRequired,
});
export const serverType = PropTypes.oneOfType([
regularServerType,
notFoundServerType,
]);

View File

@@ -0,0 +1,22 @@
import { pipe, prop } from 'ramda';
import { homepage } from '../../../package.json';
import { createServers } from './servers';
const responseToServersList = pipe(
prop('data'),
(value) => {
if (!Array.isArray(value)) {
throw new Error('Value is not an array');
}
return value;
},
);
export const fetchServers = ({ get }) => () => async (dispatch) => {
const remoteList = await get(`${homepage}/servers.json`)
.then(responseToServersList)
.catch(() => []);
dispatch(createServers(remoteList));
};

View File

@@ -1,6 +1,7 @@
import { createAction, handleActions } from 'redux-actions';
import { identity, memoizeWith, pipe } from 'ramda';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionIsValidSemVer } from '../../utils/utils';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
@@ -12,26 +13,56 @@ export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
const initialState = null;
const versionToSemVer = pipe(
(version) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
toSemVer(MIN_FALLBACK_VERSION)
);
const getServerVersion = memoizeWith(identity, (serverId, health) => health().then(({ version }) => ({
version: versionToSemVer(version),
printableVersion: versionToPrintable(version),
})));
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId) => async (
dispatch,
getState
) => {
dispatch(resetSelectedServer());
dispatch(resetShortUrlParams());
const selectedServer = findServerById(serverId);
const { health } = await buildShlinkApiClient(selectedServer);
const version = await health()
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
.catch(() => MIN_FALLBACK_VERSION);
const { servers } = getState();
const selectedServer = servers[serverId];
dispatch({
type: SELECT_SERVER,
selectedServer: {
...selectedServer,
version,
},
});
if (!selectedServer) {
dispatch({
type: SELECT_SERVER,
selectedServer: { serverNotFound: true },
});
return;
}
try {
const { health } = buildShlinkApiClient(selectedServer);
const { version, printableVersion } = await getServerVersion(serverId, health);
dispatch({
type: SELECT_SERVER,
selectedServer: {
...selectedServer,
version,
printableVersion,
},
});
dispatch(loadMercureInfo());
} catch (e) {
dispatch({
type: SELECT_SERVER,
selectedServer: { ...selectedServer, serverNotReachable: true },
});
}
};
export default handleActions({

View File

@@ -1,61 +0,0 @@
import { handleActions } from 'redux-actions';
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
import { v4 as uuid } from 'uuid';
import { homepage } from '../../../package.json';
/* eslint-disable padding-line-between-statements */
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
/* eslint-enable padding-line-between-statements */
const initialState = {
list: {},
loading: false,
};
const assocId = (server) => assoc('id', server.id || uuid(), server);
export default handleActions({
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
}, initialState);
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
dispatch({ type: FETCH_SERVERS_START });
const localList = listServers();
if (!isEmpty(localList)) {
dispatch({ type: FETCH_SERVERS, list: localList });
return;
}
// If local list is empty, try to fetch it remotely (making sure it's an array) and calculate IDs for every server
const getDataAsArrayWithIds = pipe(
prop('data'),
(value) => {
if (!Array.isArray(value)) {
throw new Error('Value is not an array');
}
return value;
},
map(assocId),
);
const remoteList = await get(`${homepage}/servers.json`)
.then(getDataAsArrayWithIds)
.catch(() => []);
createServers(remoteList);
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
};
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
export const createServers = ({ createServers }, listServersAction) => pipe(
map(assocId),
createServers,
listServersAction
);

View File

@@ -0,0 +1,35 @@
import { handleActions } from 'redux-actions';
import { pipe, assoc, map, reduce, dissoc } from 'ramda';
import { v4 as uuid } from 'uuid';
/* eslint-disable padding-line-between-statements */
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
/* eslint-enable padding-line-between-statements */
const initialState = {};
const assocId = (server) => assoc('id', server.id || uuid(), server);
export default handleActions({
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
[EDIT_SERVER]: (state, { serverId, serverData }) => !state[serverId]
? state
: assoc(serverId, { ...state[serverId], ...serverData }, state),
}, initialState);
export const createServer = (server) => createServers([ server ]);
const serversListToMap = reduce((acc, server) => assoc(server.id, server, acc), {});
export const createServers = pipe(
map(assocId),
serversListToMap,
(newServers) => ({ type: CREATE_SERVERS, newServers })
);
export const editServer = (serverId, serverData) => ({ type: EDIT_SERVER, serverId, serverData });
export const deleteServer = ({ id }) => ({ type: DELETE_SERVER, serverId: id });

View File

@@ -25,14 +25,14 @@ const saveCsv = (window, csv) => {
};
export default class ServersExporter {
constructor(serversService, window, csvjson) {
this.serversService = serversService;
constructor(storage, window, csvjson) {
this.storage = storage;
this.window = window;
this.csvjson = csvjson;
}
exportServers = async () => {
const servers = values(this.serversService.listServers()).map(dissoc('id'));
const servers = values(this.storage.get('servers') || {}).map(dissoc('id'));
try {
const csv = this.csvjson.toCSV(servers, {

View File

@@ -1,28 +0,0 @@
import { assoc, dissoc, reduce } from 'ramda';
const SERVERS_STORAGE_KEY = 'servers';
export default class ServersService {
constructor(storage) {
this.storage = storage;
}
listServers = () => this.storage.get(SERVERS_STORAGE_KEY) || {};
findServerById = (serverId) => this.listServers()[serverId];
createServer = (server) => this.createServers([ server ]);
createServers = (servers) => {
const allServers = reduce(
(serversObj, server) => assoc(server.id, server, serversObj),
this.listServers(),
servers
);
this.storage.set(SERVERS_STORAGE_KEY, allServers);
};
deleteServer = ({ id }) =>
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
}

View File

@@ -3,22 +3,26 @@ import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal';
import DeleteServerButton from '../DeleteServerButton';
import { EditServer } from '../EditServer';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers';
import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError';
import ServersImporter from './ServersImporter';
import ServersService from './ServersService';
import ServersExporter from './ServersExporter';
const provideServices = (bottle, connect, withRouter) => {
// Components
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout');
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', withRouter);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter);
@@ -32,18 +36,21 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
// Services
bottle.constant('csvjson', csvjson);
bottle.service('ServersImporter', ServersImporter, 'csvjson');
bottle.service('ServersService', ServersService, 'Storage');
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
// Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
bottle.serviceFactory('createServer', () => createServer);
bottle.serviceFactory('createServers', () => createServers);
bottle.serviceFactory('deleteServer', () => deleteServer);
bottle.serviceFactory('editServer', () => editServer);
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Card, CardBody, CardHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import Checkbox from '../utils/Checkbox';
import { SettingsType } from './reducers/settings';
const propTypes = {
settings: SettingsType,
setRealTimeUpdates: PropTypes.func,
};
const RealTimeUpdates = ({ settings: { realTimeUpdates }, setRealTimeUpdates }) => (
<Card>
<CardHeader>Real-time updates</CardHeader>
<CardBody>
<Checkbox checked={realTimeUpdates.enabled} onChange={setRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</Checkbox>
</CardBody>
</Card>
);
RealTimeUpdates.propTypes = propTypes;
export default RealTimeUpdates;

10
src/settings/Settings.js Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import NoMenuLayout from '../common/NoMenuLayout';
const Settings = (RealTimeUpdates) => () => (
<NoMenuLayout>
<RealTimeUpdates />
</NoMenuLayout>
);
export default Settings;

View File

@@ -0,0 +1,25 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES';
export const SettingsType = PropTypes.shape({
realTimeUpdates: PropTypes.shape({
enabled: PropTypes.bool.isRequired,
}),
});
const initialState = {
realTimeUpdates: {
enabled: true,
},
};
export default handleActions({
[SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => ({ ...state, realTimeUpdates }),
}, initialState);
export const setRealTimeUpdates = (enabled) => ({
type: SET_REAL_TIME_UPDATES,
realTimeUpdates: { enabled },
});

View File

@@ -0,0 +1,16 @@
import RealTimeUpdates from '../RealTimeUpdates';
import Settings from '../Settings';
import { setRealTimeUpdates } from '../reducers/settings';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdates' ]));
// Actions
bottle.serviceFactory('setRealTimeUpdates', () => setRealTimeUpdates);
};
export default provideServices;

View File

@@ -1,55 +1,66 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react';
import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React, { useState } from 'react';
import { Collapse, FormGroup, Input } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils';
import { versionMatch } from '../utils/helpers/version';
import { hasValue } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
const CreateShortUrl = (
TagsSelector,
CreateShortUrlResult,
ForServerVersion
) => class CreateShortUrl extends React.Component {
static propTypes = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
selectedServer: serverType,
};
const propTypes = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
selectedServer: serverType,
};
state = {
longUrl: '',
tags: [],
customSlug: undefined,
domain: undefined,
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
findIfExists: false,
moreOptionsVisible: false,
};
const initialState = {
longUrl: '',
tags: [],
customSlug: '',
shortCodeLength: '',
domain: '',
validSince: undefined,
validUntil: undefined,
maxVisits: '',
findIfExists: false,
};
render() {
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
const CreateShortUrlComp = ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }) => {
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlCreation(initialState);
const save = (e) => {
e.preventDefault();
const shortUrlData = {
...shortUrlCreation,
validSince: formatDate(shortUrlCreation.validSince),
validUntil: formatDate(shortUrlCreation.validUntil),
};
createShortUrl(shortUrlData).then(reset).catch(() => {});
};
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<FormGroup>
<Input
id={id}
type={type}
placeholder={placeholder}
value={this.state[id]}
onChange={(e) => this.setState({ [id]: e.target.value })}
value={shortUrlCreation[id]}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
{...props}
/>
</FormGroup>
@@ -57,105 +68,106 @@ const CreateShortUrl = (
const renderDateInput = (id, placeholder, props = {}) => (
<div className="form-group">
<DateInput
selected={this.state[id]}
selected={shortUrlCreation[id]}
placeholderText={placeholder}
isClearable
onChange={(date) => this.setState({ [id]: date })}
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
{...props}
/>
</div>
);
const save = (e) => {
e.preventDefault();
createShortUrl(pipe(
dissoc('moreOptionsVisible'),
assoc('validSince', formatDate(this.state.validSince)),
assoc('validUntil', formatDate(this.state.validUntil))
)(this.state));
};
const currentServerVersion = this.props.selectedServer ? this.props.selectedServer.version : '';
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
const currentServerVersion = selectedServer && selectedServer.version;
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
return (
<div className="shlink-container">
<form onSubmit={save}>
<form onSubmit={save}>
<div className="form-group">
<input
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={shortUrlCreation.longUrl}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
/>
</div>
<Collapse isOpen={moreOptionsVisible}>
<div className="form-group">
<input
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={this.state.longUrl}
onChange={(e) => this.setState({ longUrl: e.target.value })}
/>
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
</div>
<Collapse isOpen={this.state.moreOptionsVisible}>
<div className="form-group">
<TagsSelector tags={this.state.tags} onChange={changeTags} />
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('customSlug', 'Custom slug')}
</div>
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('customSlug', 'Custom slug')}
</div>
<div className="col-sm-6">
{renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
</div>
<div className="col-sm-4">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
</div>
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
</div>
<div className="col-sm-3">
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
<div className="col-sm-4">
{renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={this.state.findIfExists}
onChange={(findIfExists) => this.setState({ findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForServerVersion>
</Collapse>
<div>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)}
>
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
</form>
</div>
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-4">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
</div>
<div className="col-sm-4">
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
</div>
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForServerVersion>
</Collapse>
<div>
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
</form>
);
}
};
CreateShortUrlComp.propTypes = propTypes;
return CreateShortUrlComp;
};
export default CreateShortUrl;

View File

@@ -2,7 +2,8 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types';
import { rangeOf } from '../utils/utils';
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
import './Paginator.scss';
const propTypes = {
serverId: PropTypes.string.isRequired,
@@ -12,7 +13,7 @@ const propTypes = {
}),
};
export default function Paginator({ paginator = {}, serverId }) {
const Paginator = ({ paginator = {}, serverId }) => {
const { currentPage, pagesCount = 0 } = paginator;
if (pagesCount <= 1) {
@@ -20,8 +21,12 @@ export default function Paginator({ paginator = {}, serverId }) {
}
const renderPages = () =>
rangeOf(pagesCount, (pageNumber) => (
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
<PaginationItem
key={keyForPage(pageNumber, index)}
disabled={isPageDisabled(pageNumber)}
active={currentPage === pageNumber}
>
<PaginationLink
tag={Link}
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
@@ -32,7 +37,7 @@ export default function Paginator({ paginator = {}, serverId }) {
));
return (
<Pagination listClassName="flex-wrap">
<Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink
previous
@@ -50,6 +55,8 @@ export default function Paginator({ paginator = {}, serverId }) {
</PaginationItem>
</Pagination>
);
}
};
Paginator.propTypes = propTypes;
export default Paginator;

View File

@@ -0,0 +1,7 @@
.short-urls-paginator {
position: sticky;
bottom: 0;
background-color: rgba(white, .8);
padding: .75rem 0;
border-top: 1px solid rgba(black, .125);
}

View File

@@ -7,7 +7,7 @@ import moment from 'moment';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import DateRangeRow from '../utils/DateRangeRow';
import { formatDate } from '../utils/utils';
import { formatDate } from '../utils/helpers/date';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
@@ -36,12 +36,16 @@ const SearchBar = (colorGenerator, ForServerVersion) => {
<ForServerVersion minVersion="1.21.0">
<div className="mt-3">
<DateRangeRow
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
<div className="row">
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
<DateRangeRow
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</div>
</div>
</div>
</ForServerVersion>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import Paginator from './Paginator';
@@ -14,16 +14,22 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
const { match: { params }, shortUrlsList } = props;
const { page, serverId } = params;
const { data = [], pagination } = shortUrlsList;
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
// Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${serverId}_${page}`;
// Without it, pagination on the URL will not make the component to be refreshed
useEffect(() => {
setUrlsListKey(`${serverId}_${page}`);
}, [ serverId, page ]);
return (
<div className="shlink-container">
<React.Fragment>
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
<div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
</React.Fragment>
);
};

View File

@@ -1,12 +1,14 @@
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, values } from 'ramda';
import React from 'react';
import React, { useState, useEffect } from 'react';
import qs from 'qs';
import PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { useMercureTopicBinding } from '../mercure/helpers';
import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss';
@@ -18,118 +20,112 @@ export const SORTABLE_FIELDS = {
visits: 'Visits',
};
const propTypes = {
listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
match: PropTypes.object,
location: PropTypes.object,
loading: PropTypes.bool,
error: PropTypes.bool,
shortUrlsList: PropTypes.arrayOf(shortUrlType),
selectedServer: serverType,
createNewVisit: PropTypes.func,
loadMercureInfo: PropTypes.func,
mercureInfo: MercureInfoType,
};
// FIXME Replace with typescript: (ShortUrlsRow component)
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
static propTypes = {
listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
match: PropTypes.object,
location: PropTypes.object,
loading: PropTypes.bool,
error: PropTypes.bool,
shortUrlsList: PropTypes.arrayOf(shortUrlType),
selectedServer: serverType,
};
refreshList = (extraParams) => {
const { listShortUrls, shortUrlsListParams } = this.props;
listShortUrls({
...shortUrlsListParams,
...extraParams,
const ShortUrlsList = (ShortUrlsRow) => {
const ShortUrlsListComp = ({
listShortUrls,
resetShortUrlParams,
shortUrlsListParams,
match,
location,
loading,
error,
shortUrlsList,
selectedServer,
createNewVisit,
loadMercureInfo,
mercureInfo,
}) => {
const { orderBy } = shortUrlsListParams;
const [ order, setOrder ] = useState({
orderField: orderBy && head(keys(orderBy)),
orderDir: orderBy && head(values(orderBy)),
});
};
handleOrderBy = (orderField, orderDir) => {
this.setState({ orderField, orderDir });
this.refreshList({ orderBy: { [orderField]: orderDir } });
};
orderByColumn = (columnName) => () =>
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
renderOrderIcon = (field) => {
if (this.state.orderField !== field) {
return null;
}
if (!this.state.orderDir) {
return null;
}
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className="short-urls-list__header-icon"
/>
);
};
constructor(props) {
super(props);
const { orderBy } = props.shortUrlsListParams;
this.state = {
orderField: orderBy ? head(keys(orderBy)) : undefined,
orderDir: orderBy ? head(values(orderBy)) : undefined,
const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
const handleOrderBy = (orderField, orderDir) => {
setOrder({ orderField, orderDir });
refreshList({ orderBy: { [orderField]: orderDir } });
};
}
const orderByColumn = (columnName) => () =>
handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir));
const renderOrderIcon = (field) => {
if (order.orderField !== field) {
return null;
}
componentDidMount() {
const { match: { params }, location, shortUrlsListParams } = this.props;
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
if (!order.orderDir) {
return null;
}
this.refreshList({ page: params.page, tags });
}
componentWillUnmount() {
const { resetShortUrlParams } = this.props;
resetShortUrlParams();
}
renderShortUrls() {
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
if (error) {
return (
<tr>
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
<FontAwesomeIcon
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className="short-urls-list__header-icon"
/>
);
}
};
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
}
if (loading) {
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
}
if (loading) {
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
}
if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
}
if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
}
return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow
shortUrl={shortUrl}
selectedServer={selectedServer}
key={shortUrl.shortCode}
refreshList={this.refreshList}
shortUrlsListParams={shortUrlsListParams}
/>
));
}
return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl}
selectedServer={selectedServer}
refreshList={refreshList}
shortUrlsListParams={shortUrlsListParams}
/>
));
};
useEffect(() => {
const { params } = match;
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
refreshList({ page: params.page, tags });
return resetShortUrlParams;
}, []);
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
render() {
return (
<React.Fragment>
<div className="d-block d-md-none mb-3">
<SortingDropdown
items={SORTABLE_FIELDS}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
onChange={this.handleOrderBy}
orderField={order.orderField}
orderDir={order.orderDir}
onChange={handleOrderBy}
/>
</div>
<table className="table table-striped table-hover">
@@ -137,42 +133,46 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
<tr>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('dateCreated')}
onClick={orderByColumn('dateCreated')}
>
{this.renderOrderIcon('dateCreated')}
{renderOrderIcon('dateCreated')}
Created at
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('shortCode')}
onClick={orderByColumn('shortCode')}
>
{this.renderOrderIcon('shortCode')}
{renderOrderIcon('shortCode')}
Short URL
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('longUrl')}
onClick={orderByColumn('longUrl')}
>
{this.renderOrderIcon('longUrl')}
{renderOrderIcon('longUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('visits')}
onClick={orderByColumn('visits')}
>
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
</th>
<th className="short-urls-list__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{this.renderShortUrls()}
{renderShortUrls()}
</tbody>
</table>
</React.Fragment>
);
}
};
ShortUrlsListComp.propTypes = propTypes;
return ShortUrlsListComp;
};
export default ShortUrlsList;

View File

@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './UseExistingIfFoundInfoIcon.scss';
import { useToggle } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks';
const renderInfoModal = (isOpen, toggle) => (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
@@ -38,7 +38,7 @@ const renderInfoModal = (isOpen, toggle) => (
);
const UseExistingIfFoundInfoIcon = () => {
const [ isModalOpen, toggleModal ] = useToggle(false);
const [ isModalOpen, toggleModal ] = useToggle();
return (
<React.Fragment>

View File

@@ -1,28 +1,26 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import React from 'react';
import React, { useEffect } from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Card, CardBody, Tooltip } from 'reactstrap';
import PropTypes from 'prop-types';
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
import './CreateShortUrlResult.scss';
const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult extends React.Component {
static propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
result: createShortUrlResultType,
};
const propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
result: createShortUrlResultType,
};
state = { showCopyTooltip: false };
const CreateShortUrlResult = (useStateFlagTimeout) => {
const CreateShortUrlResultComp = ({ error, result, resetCreateShortUrl }) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
componentDidMount() {
this.props.resetCreateShortUrl();
}
render() {
const { error, result } = this.props;
useEffect(() => {
resetCreateShortUrl();
}, []);
if (error) {
return (
@@ -31,19 +29,19 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
</Card>
);
}
if (isNil(result)) {
return null;
}
const { shortUrl } = result;
const onCopy = () => stateFlagTimeout(this.setState.bind(this), 'showCopyTooltip');
return (
<Card inverse className="bg-main mt-3">
<CardBody>
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={onCopy}>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
<button
className="btn btn-light btn-sm create-short-url-result__copy-btn"
id="copyBtn"
@@ -53,13 +51,17 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
</button>
</CopyToClipboard>
<Tooltip placement="left" isOpen={this.state.showCopyTooltip} target="copyBtn">
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
Copied!
</Tooltip>
</CardBody>
</Card>
);
}
};
CreateShortUrlResultComp.propTypes = propTypes;
return CreateShortUrlResultComp;
};
export default CreateShortUrlResult;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { identity, pipe } from 'ramda';
@@ -7,21 +7,28 @@ import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
export default class DeleteShortUrlModal extends React.Component {
static propTypes = {
shortUrl: shortUrlType,
toggle: PropTypes.func,
isOpen: PropTypes.bool,
shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func,
};
const propTypes = {
shortUrl: shortUrlType,
toggle: PropTypes.func,
isOpen: PropTypes.bool,
shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func,
};
state = { inputValue: '' };
handleDeleteUrl = (e) => {
const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }) => {
const [ inputValue, setInputValue ] = useState('');
useEffect(() => resetDeleteShortUrl, []);
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
const handleDeleteUrl = (e) => {
e.preventDefault();
const { deleteShortUrl, shortUrl, toggle } = this.props;
const { shortCode, domain } = shortUrl;
deleteShortUrl(shortCode, domain)
@@ -29,62 +36,51 @@ export default class DeleteShortUrlModal extends React.Component {
.catch(identity);
};
componentWillUnmount() {
const { resetDeleteShortUrl } = this.props;
return (
<Modal isOpen={isOpen} toggle={close} centered>
<form onSubmit={handleDeleteUrl}>
<ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
resetDeleteShortUrl();
}
<input
type="text"
className="form-control"
placeholder="Insert the short code of the URL"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props;
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
</div>
)}
{hasErrorOtherThanThreshold && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :(
</div>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button
type="submit"
className="btn btn-danger"
disabled={inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
>
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
</button>
</ModalFooter>
</form>
</Modal>
);
};
return (
<Modal isOpen={isOpen} toggle={close} centered>
<form onSubmit={this.handleDeleteUrl}>
<ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
DeleteShortUrlModal.propTypes = propTypes;
<input
type="text"
className="form-control"
placeholder="Insert the short code of the URL"
value={this.state.inputValue}
onChange={(e) => this.setState({ inputValue: e.target.value })}
/>
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
</div>
)}
{hasErrorOtherThanThreshold && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :(
</div>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button
type="submit"
className="btn btn-danger"
disabled={this.state.inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
>
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
</button>
</ModalFooter>
</form>
</Modal>
);
}
}
export default DeleteShortUrlModal;

View File

@@ -9,7 +9,7 @@ import { isEmpty, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput';
import { formatIsoDate } from '../../utils/utils';
import { formatIsoDate } from '../../utils/helpers/date';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
@@ -26,9 +26,7 @@ const dateOrUndefined = (shortUrl, dateName) => {
return date && moment(date);
};
const EditMetaModal = (
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }
) => {
const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => {
const { saving, error } = shortUrlMeta;
const url = shortUrl && (shortUrl.shortUrl || '');
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));

View File

@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { shortUrlType } from '../reducers/shortUrlsList';
import { ShortUrlEditionType } from '../reducers/shortUrlEdition';
import { hasValue } from '../../utils/utils';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlEdition: ShortUrlEditionType,
editShortUrl: PropTypes.func,
};
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }) => {
const { saving, error } = shortUrlEdition;
const url = shortUrl && (shortUrl.shortUrl || '');
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
const doEdit = () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
Edit long URL for <ExternalLink href={url} />
</ModalHeader>
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
<ModalBody>
<FormGroup className="mb-0">
<Input
type="url"
required
placeholder="Long URL"
value={longUrl}
onChange={(e) => setLongUrl(e.target.value)}
/>
</FormGroup>
{error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the long URL :(
</div>
)}
</ModalBody>
<ModalFooter>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="primary" disabled={saving || !hasValue(longUrl)}>{saving ? 'Saving...' : 'Save'}</Button>
</ModalFooter>
</form>
</Modal>
);
};
EditShortUrlModal.propTypes = propTypes;
export default EditShortUrlModal;

View File

@@ -1,52 +1,37 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import { pipe } from 'ramda';
import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList';
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func,
resetShortUrlsTags: PropTypes.func,
};
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func,
resetShortUrlsTags: PropTypes.func,
};
saveTags = () => {
const { editShortUrlTags, shortUrl, toggle } = this.props;
const EditTagsModal = (TagsSelector) => {
const EditTagsModalComp = ({ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }) => {
const [ selectedTags, setSelectedTags ] = useState(shortUrl.tags || []);
editShortUrlTags(shortUrl.shortCode, shortUrl.domain, this.state.tags)
useEffect(() => resetShortUrlsTags, []);
const url = shortUrl && (shortUrl.shortUrl || '');
const saveTags = () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
.then(toggle)
.catch(() => {});
};
componentDidMount() {
const { resetShortUrlsTags } = this.props;
resetShortUrlsTags();
}
constructor(props) {
super(props);
this.state = { tags: props.shortUrl.tags };
}
render() {
const { isOpen, toggle, shortUrl, shortUrlTags, resetShortUrlsTags } = this.props;
const url = shortUrl && (shortUrl.shortUrl || '');
const close = pipe(resetShortUrlsTags, toggle);
return (
<Modal isOpen={isOpen} toggle={close} centered>
<ModalHeader toggle={close}>
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
Edit tags for <ExternalLink href={url} />
</ModalHeader>
<ModalBody>
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
<TagsSelector tags={selectedTags} onChange={(tags) => setSelectedTags(tags)} />
{shortUrlTags.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the tags :(
@@ -54,19 +39,18 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={close}>Cancel</button>
<button
className="btn btn-primary"
type="button"
disabled={shortUrlTags.saving}
onClick={() => this.saveTags()}
>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-primary" type="button" disabled={shortUrlTags.saving} onClick={saveTags}>
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
</button>
</ModalFooter>
</Modal>
);
}
};
EditTagsModalComp.propTypes = propTypes;
return EditTagsModalComp;
};
export default EditTagsModal;

View File

@@ -1,24 +1,31 @@
import React from 'react';
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import classNames from 'classnames';
import { serverType } from '../../servers/prop-types';
import { prettify } from '../../utils/helpers/numbers';
import { shortUrlType } from '../reducers/shortUrlsList';
import './ShortUrlVisitsCount.scss';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlVisitsCount.scss';
const propTypes = {
visitsCount: PropTypes.number.isRequired,
shortUrl: shortUrlType,
selectedServer: serverType,
active: PropTypes.bool,
};
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }) => {
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
<strong>{visitsCount}</strong>
<strong
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
>
{prettify(visitsCount)}
</strong>
</VisitStatsLink>
);
@@ -26,19 +33,27 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
return visitsLink;
}
const prettifiedMaxVisits = prettify(maxVisits);
const tooltipRef = useRef();
return (
<React.Fragment>
<span className="indivisible">
{visitsLink}
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
{' '}/ {maxVisits}{' '}
<small
className="short-urls-visits-count__max-visits-control"
ref={(el) => {
tooltipRef.current = el;
}}
>
{' '}/ {prettifiedMaxVisits}{' '}
<sup>
<FontAwesomeIcon icon={infoIcon} />
</sup>
</small>
</span>
<UncontrolledTooltip target="maxVisitsControl" placement="bottom">
This short URL will not accept more than <b>{maxVisits}</b> visits.
<UncontrolledTooltip target={() => tooltipRef.current} placement="bottom">
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
</UncontrolledTooltip>
</React.Fragment>
);

View File

@@ -1,3 +1,12 @@
.short-urls-visits-count__max-visits-control {
cursor: help;
}
.short-url-visits-count__amount {
transition: transform .3s ease;
display: inline-block;
}
.short-url-visits-count__amount--big {
transform: scale(1.5);
}

View File

@@ -1,8 +1,11 @@
import { isEmpty } from 'ramda';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import Moment from 'react-moment';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
@@ -10,76 +13,86 @@ import Tag from '../../tags/helpers/Tag';
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import './ShortUrlsRow.scss';
const propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType,
shortUrl: shortUrlType,
};
const ShortUrlsRow = (
ShortUrlsRowMenu,
colorGenerator,
stateFlagTimeout
) => class ShortUrlsRow extends React.Component {
static propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType,
shortUrl: shortUrlType,
};
useStateFlagTimeout
) => {
const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout();
const [ active, setActive ] = useStateFlagTimeout(false, 500);
const isFirstRun = useRef(true);
state = { copiedToClipboard: false };
const renderTags = (tags) => {
if (isEmpty(tags)) {
return <i className="indivisible"><small>No tags</small></i>;
}
renderTags(tags) {
if (isEmpty(tags)) {
return <i className="nowrap"><small>No tags</small></i>;
}
const selectedTags = shortUrlsListParams.tags || [];
const { refreshList, shortUrlsListParams } = this.props;
const selectedTags = shortUrlsListParams.tags || [];
return tags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
/>
));
};
return tags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
/>
));
}
render() {
const { shortUrl, selectedServer } = this.props;
useEffect(() => {
if (isFirstRun.current) {
isFirstRun.current = false;
} else {
setActive(true);
}
}, [ shortUrl.visitsCount ]);
return (
<tr className="short-urls-row">
<td className="nowrap short-urls-row__cell" data-th="Created at: ">
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td>
<td className="short-urls-row__cell" data-th="Short URL: ">
<ExternalLink href={shortUrl.shortUrl} />
<span className="indivisible short-urls-row__cell--relative">
<ExternalLink href={shortUrl.shortUrl} />
<CopyToClipboard text={shortUrl.shortUrl} onCopy={setCopiedToClipboard}>
<FontAwesomeIcon icon={copyIcon} className="ml-2 short-urls-row__copy-btn" />
</CopyToClipboard>
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
Copied short URL!
</span>
</span>
</td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<ExternalLink href={shortUrl.longUrl} />
</td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</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: ">
<ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
active={active}
/>
</td>
<td className="short-urls-row__cell short-urls-row__cell--relative">
<small
className="badge badge-warning short-urls-row__copy-hint"
hidden={!this.state.copiedToClipboard}
>
Copied short URL!
</small>
<ShortUrlsRowMenu
selectedServer={selectedServer}
shortUrl={shortUrl}
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}
/>
<td className="short-urls-row__cell">
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
</td>
</tr>
);
}
};
ShortUrlsRowComp.propTypes = propTypes;
return ShortUrlsRowComp;
};
export default ShortUrlsRow;

View File

@@ -35,6 +35,7 @@
}
}
}
.short-urls-row__cell--break {
word-break: break-all;
}
@@ -43,11 +44,20 @@
position: relative;
}
.short-urls-row__cell--big {
transform: scale(1.5);
}
.short-urls-row__copy-btn {
cursor: pointer;
font-size: 1.2rem;
}
.short-urls-row__copy-hint {
@include vertical-align();
right: 100%;
@include vertical-align(translateX(10px));
box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
@media (max-width: $smMax) {
right: calc(100% + 10px);
@include vertical-align(translateX(calc(-100% - 20px)));
}
}

View File

@@ -1,4 +1,4 @@
import { faCopy as copyIcon, faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
import { faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
import {
faTags as tagsIcon,
faChartPie as pieChartIcon,
@@ -6,53 +6,37 @@ import {
faQrcode as qrIcon,
faMinusCircle as deleteIcon,
faEdit as editIcon,
faLink as linkIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
import { useToggle } from '../../utils/helpers/hooks';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = (
DeleteShortUrlModal,
EditTagsModal,
EditMetaModal,
ForServerVersion
) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
shortUrl: shortUrlType,
};
const propTypes = {
selectedServer: serverType,
shortUrl: shortUrlType,
};
state = {
isOpen: false,
isQrModalOpen: false,
isPreviewModalOpen: false,
isTagsModalOpen: false,
isMetaModalOpen: false,
isDeleteModalOpen: false,
};
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() {
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal, EditMetaModal, EditShortUrlModal, ForServerVersion) => {
const ShortUrlsRowMenuComp = ({ shortUrl, selectedServer }) => {
const [ isOpen, toggle ] = useToggle();
const [ isQrModalOpen, toggleQrCode ] = useToggle();
const [ isPreviewModalOpen, togglePreview ] = useToggle();
const [ isTagsModalOpen, toggleTags ] = useToggle();
const [ isMetaModalOpen, toggleMeta ] = useToggle();
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewModalOpen');
const toggleTags = toggleModal('isTagsModalOpen');
const toggleMeta = toggleModal('isMetaModalOpen');
const toggleDelete = toggleModal('isDeleteModalOpen');
return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
@@ -64,47 +48,48 @@ const ShortUrlsRowMenu = (
<DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
</DropdownItem>
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
<EditTagsModal shortUrl={shortUrl} isOpen={isTagsModalOpen} toggle={toggleTags} />
<ForServerVersion minVersion="1.18.0">
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
</ForServerVersion>
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
<DropdownItem divider />
<ForServerVersion maxVersion="1.x">
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
<ForServerVersion minVersion="2.1.0">
<DropdownItem onClick={toggleEdit}>
<FontAwesomeIcon icon={linkIcon} fixedWidth /> Edit long URL
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
<EditShortUrlModal shortUrl={shortUrl} isOpen={isEditModalOpen} toggle={toggleEdit} />
</ForServerVersion>
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
<QrCodeModal url={completeShortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
<ForServerVersion maxVersion="1.x">
<DropdownItem divider />
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={isPreviewModalOpen} toggle={togglePreview} />
</ForServerVersion>
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
<DropdownItem>
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
</DropdownItem>
</CopyToClipboard>
<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>
);
}
};
ShortUrlsRowMenuComp.propTypes = propTypes;
return ShortUrlsRowMenuComp;
};
export default ShortUrlsRowMenu;

View File

@@ -0,0 +1,9 @@
import { isNil } from 'ramda';
export const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};

View File

@@ -31,8 +31,7 @@ export default handleActions({
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
dispatch({ type: CREATE_SHORT_URL_START });
const { createShortUrl } = await buildShlinkApiClient(getState);
const { createShortUrl } = buildShlinkApiClient(getState);
try {
const result = await createShortUrl(data);
@@ -40,6 +39,8 @@ export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatc
dispatch({ type: CREATE_SHORT_URL, result });
} catch (e) {
dispatch({ type: CREATE_SHORT_URL_ERROR });
throw e;
}
};

View File

@@ -32,8 +32,7 @@ export default handleActions({
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START });
const { deleteShortUrl } = await buildShlinkApiClient(getState);
const { deleteShortUrl } = buildShlinkApiClient(getState);
try {
await deleteShortUrl(shortCode, domain);

View File

@@ -0,0 +1,42 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
/* eslint-enable padding-line-between-statements */
export const ShortUrlEditionType = PropTypes.shape({
shortCode: PropTypes.string,
longUrl: PropTypes.string,
saving: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
});
const initialState = {
shortCode: null,
longUrl: null,
saving: false,
error: false,
};
export default handleActions({
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[SHORT_URL_EDITED]: (state, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }),
}, initialState);
export const editShortUrl = (buildShlinkApiClient) => (shortCode, domain, longUrl) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_START });
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, { longUrl });
dispatch({ shortCode, longUrl, domain, type: SHORT_URL_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_ERROR });
throw e;
}
};

View File

@@ -37,7 +37,7 @@ export default handleActions({
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, meta);

View File

@@ -31,7 +31,7 @@ export default handleActions({
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
const { updateShortUrlTags } = buildShlinkApiClient(getState);
try {
const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);

View File

@@ -1,9 +1,12 @@
import { handleActions } from 'redux-actions';
import { assoc, assocPath, isNil, reject } from 'ramda';
import { assoc, assocPath, reject } from 'ramda';
import PropTypes from 'prop-types';
import { shortUrlMatches } from '../helpers';
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
import { SHORT_URL_EDITED } from './shortUrlEdition';
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@@ -27,14 +30,6 @@ const initialState = {
error: false,
};
const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls.data.map(
@@ -54,12 +49,21 @@ export default handleActions({
),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
[CREATE_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
? assoc('visitsCount', visitsCount, shortUrl)
: shortUrl
),
state
),
}, initialState);
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START });
const { listShortUrls } = await buildShlinkApiClient(getState);
const { listShortUrls } = buildShlinkApiClient(getState);
try {
const shortUrls = await listShortUrls(params);

View File

@@ -9,6 +9,7 @@ import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal';
import EditMetaModal from '../helpers/EditMetaModal';
import EditShortUrlModal from '../helpers/EditShortUrlModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
@@ -16,6 +17,7 @@ import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletio
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
import { editShortUrl } from '../reducers/shortUrlEdition';
const provideServices = (bottle, connect) => {
// Components
@@ -29,11 +31,11 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'shortUrlsListParams' ],
[ 'listShortUrls', 'resetShortUrlParams' ]
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
bottle.serviceFactory(
'ShortUrlsRowMenu',
@@ -41,9 +43,10 @@ const provideServices = (bottle, connect) => {
'DeleteShortUrlModal',
'EditTagsModal',
'EditMetaModal',
'EditShortUrlModal',
'ForServerVersion'
);
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
bottle.decorator(
@@ -60,6 +63,9 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
@@ -75,6 +81,8 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
};
export default provideServices;

View File

@@ -1,47 +1,84 @@
import { Card, CardBody } from 'reactstrap';
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';
import { serverType } from '../servers/prop-types';
import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks';
import TagBullet from './helpers/TagBullet';
import './TagCard.scss';
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
static propTypes = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
};
const propTypes = {
tag: PropTypes.string,
tagStats: PropTypes.shape({
shortUrlsCount: PropTypes.number,
visitsCount: PropTypes.number,
}),
selectedServer: serverType,
displayed: PropTypes.bool,
toggle: PropTypes.func,
};
state = { isDeleteModalOpen: false, isEditModalOpen: false };
const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGenerator) => {
const TagCardComp = ({ tag, tagStats, selectedServer, displayed, toggle }) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
render() {
const { tag, currentServerId } = this.props;
const toggleDelete = () =>
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
const toggleEdit = () =>
this.setState(({ isEditModalOpen }) => ({ isEditModalOpen: !isEditModalOpen }));
const { id } = selectedServer;
const shortUrlsLink = `/server/${id}/list-short-urls/1?tag=${tag}`;
return (
<Card className="tag-card">
<CardBody className="tag-card__body">
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<CardHeader className="tag-card__header">
<Button color="light" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} />
</button>
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
</Button>
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</button>
<h5 className="tag-card__tag-title">
</Button>
<h5 className="tag-card__tag-title text-ellipsis">
<TagBullet tag={tag} colorGenerator={colorGenerator} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
<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>
</h5>
</CardBody>
</CardHeader>
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
{tagStats && (
<Collapse isOpen={displayed}>
<CardBody className="tag-card__body">
<Link
to={shortUrlsLink}
className="btn btn-light 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>
<b>{prettify(tagStats.shortUrlsCount)}</b>
</Link>
<Link
to={`/server/${id}/tag/${tag}/visits`}
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
<b>{prettify(tagStats.visitsCount)}</b>
</Link>
</CardBody>
</Collapse>
)}
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
</Card>
);
}
};
TagCardComp.propTypes = propTypes;
return TagCardComp;
};
export default TagCard;

View File

@@ -1,8 +1,12 @@
.tag-card.tag-card {
background-color: #eee;
margin-bottom: .5rem;
}
.tag-card__header.tag-card__header {
background-color: #eee;
}
.tag-card__header.tag-card__header,
.tag-card__body.tag-card__body {
padding: .75rem;
}
@@ -10,9 +14,6 @@
.tag-card__tag-title {
margin: 0;
line-height: 31px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-right: 5px;
}
@@ -23,3 +24,17 @@
.tag-card__btn--last {
margin-left: 3px;
}
.tag-card__table-cell.tag-card__table-cell {
border: none;
}
.tag-card__tag-name {
color: #007bff;
cursor: pointer;
}
.tag-card__tag-name:hover {
color: #0056b3;
text-decoration: underline;
}

View File

@@ -1,84 +1,91 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { splitEvery } from 'ramda';
import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage';
import Message from '../utils/Message';
import SearchField from '../utils/SearchField';
import { serverType } from '../servers/prop-types';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { useMercureTopicBinding } from '../mercure/helpers';
import { TagsListType } from './reducers/tagsList';
const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4;
const TagsList = (TagCard) => class TagsList extends React.Component {
static propTypes = {
filterTags: PropTypes.func,
forceListTags: PropTypes.func,
tagsList: PropTypes.shape({
loading: PropTypes.bool,
error: PropTypes.bool,
filteredTags: PropTypes.arrayOf(PropTypes.string),
}),
match: PropTypes.object,
};
const propTypes = {
filterTags: PropTypes.func,
forceListTags: PropTypes.func,
tagsList: TagsListType,
selectedServer: serverType,
createNewVisit: PropTypes.func,
loadMercureInfo: PropTypes.func,
mercureInfo: MercureInfoType,
};
componentDidMount() {
const { forceListTags } = this.props;
const TagsList = (TagCard) => {
const TagListComp = (
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo }
) => {
const [ displayedTag, setDisplayedTag ] = useState();
forceListTags();
}
useEffect(() => {
forceListTags();
}, []);
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
renderContent() {
const { tagsList, match } = this.props;
const renderContent = () => {
if (tagsList.loading) {
return <Message noMargin loading />;
}
if (tagsList.loading) {
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>;
}
if (tagsList.error) {
return (
<div className="col-12">
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
</div>
);
}
const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) {
return <Message>No tags found</Message>;
}
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
if (tagsList.error) {
return (
<div className="col-12">
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
</div>
<React.Fragment>
{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>
))}
</React.Fragment>
);
}
const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) {
return <MuttedMessage>No tags found</MuttedMessage>;
}
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
};
return (
<React.Fragment>
{tagsGroups.map((group, index) => (
<div key={index} className="col-md-6 col-xl-3">
{group.map((tag) => (
<TagCard
key={tag}
tag={tag}
currentServerId={match.params.serverId}
/>
))}
</div>
))}
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
<div className="row">
{renderContent()}
</div>
</React.Fragment>
);
}
};
render() {
const { filterTags } = this.props;
TagListComp.propTypes = propTypes;
return (
<div className="shlink-container">
{!this.props.tagsList.loading &&
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
}
<div className="row">
{this.renderContent()}
</div>
</div>
);
}
return TagListComp;
};
export default TagsList;

View File

@@ -3,64 +3,45 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { tagDeleteType } from '../reducers/tagDelete';
export default class DeleteTagConfirmModal extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
deleteTag: PropTypes.func,
tagDelete: tagDeleteType,
tagDeleted: PropTypes.func,
};
doDelete = async () => {
const { tag, toggle, deleteTag } = this.props;
const propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
deleteTag: PropTypes.func,
tagDelete: tagDeleteType,
tagDeleted: PropTypes.func,
};
const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }) => {
const doDelete = async () => {
await deleteTag(tag);
this.tagWasDeleted = true;
tagDeleted(tag);
toggle();
};
handleOnClosed = () => {
if (!this.tagWasDeleted) {
return;
}
const { tagDeleted, tag } = this.props;
return (
<Modal toggle={toggle} isOpen={isOpen} centered>
<ModalHeader toggle={toggle}>
<span className="text-danger">Delete tag</span>
</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{tagDelete.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the tag :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-danger" disabled={tagDelete.deleting} onClick={doDelete}>
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
</button>
</ModalFooter>
</Modal>
);
};
tagDeleted(tag);
};
DeleteTagConfirmModal.propTypes = propTypes;
componentDidMount() {
this.tagWasDeleted = false;
}
render() {
const { tag, toggle, isOpen, tagDelete } = this.props;
return (
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={this.handleOnClosed}>
<ModalHeader toggle={toggle}>
<span className="text-danger">Delete tag</span>
</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{tagDelete.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the tag :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button
className="btn btn-danger"
disabled={tagDelete.deleting}
onClick={() => this.doDelete()}
>
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
</button>
</ModalFooter>
</Modal>
);
}
}
export default DeleteTagConfirmModal;

View File

@@ -1,109 +1,62 @@
import React from 'react';
import React, { useState } from 'react';
import { 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';
import PropTypes from 'prop-types';
import './EditTagModal.scss';
import { useToggle } from '../../utils/helpers/hooks';
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
static propTypes = {
tag: PropTypes.string,
editTag: PropTypes.func,
toggle: PropTypes.func,
tagEdited: PropTypes.func,
isOpen: PropTypes.bool,
tagEdit: PropTypes.shape({
error: PropTypes.bool,
editing: PropTypes.bool,
}),
};
const propTypes = {
tag: PropTypes.string,
editTag: PropTypes.func,
toggle: PropTypes.func,
tagEdited: PropTypes.func,
isOpen: PropTypes.bool,
tagEdit: PropTypes.shape({
error: PropTypes.bool,
editing: PropTypes.bool,
}),
};
saveTag = (e) => {
e.preventDefault();
const { tag: oldName, editTag, toggle } = this.props;
const { tag: newName, color } = this.state;
const EditTagModal = ({ getColorForKey }) => {
const EditTagModalComp = ({ tag, editTag, toggle, tagEdited, isOpen, tagEdit }) => {
const [ newTagName, setNewTagName ] = useState(tag);
const [ color, setColor ] = useState(getColorForKey(tag));
const [ showColorPicker, toggleColorPicker ] = useToggle();
const saveTag = (e) => {
e.preventDefault();
editTag(oldName, newName, color)
.then(() => {
this.tagWasEdited = true;
toggle();
})
.catch(() => {});
};
handleOnClosed = () => {
if (!this.tagWasEdited) {
return;
}
const { tag: oldName, tagEdited } = this.props;
const { tag: newName, color } = this.state;
tagEdited(oldName, newName, color);
};
constructor(props) {
super(props);
const { tag } = props;
this.state = {
showColorPicker: false,
tag,
color: getColorForKey(tag),
editTag(tag, newTagName, color)
.then(() => tagEdited(tag, newTagName, color))
.then(toggle)
.catch(() => {});
};
}
componentDidMount() {
this.tagWasEdited = false;
}
render() {
const { isOpen, toggle, tagEdit } = this.props;
const { tag, color } = this.state;
const toggleColorPicker = () =>
this.setState(({ showColorPicker }) => ({ showColorPicker: !showColorPicker }));
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.handleOnClosed}>
<form onSubmit={(e) => this.saveTag(e)}>
<Modal isOpen={isOpen} toggle={toggle} centered>
<form onSubmit={saveTag}>
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
<ModalBody>
<div className="input-group">
<div
className="input-group-prepend"
id="colorPickerBtn"
onClick={toggleColorPicker}
>
<div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
<div
className="input-group-text edit-tag-modal__color-picker-toggle"
style={{
backgroundColor: color,
borderColor: color,
}}
style={{ backgroundColor: color, borderColor: color }}
>
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
</div>
</div>
<Popover
isOpen={this.state.showColorPicker}
toggle={toggleColorPicker}
target="colorPickerBtn"
placement="right"
>
<ChromePicker
color={color}
disableAlpha
onChange={(color) => this.setState({ color: color.hex })}
/>
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
</Popover>
<input
type="text"
value={tag}
value={newTagName}
placeholder="Tag"
required
className="form-control"
onChange={(e) => this.setState({ tag: e.target.value })}
onChange={(e) => setNewTagName(e.target.value)}
/>
</div>
@@ -122,7 +75,11 @@ const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Co
</form>
</Modal>
);
}
};
EditTagModalComp.propTypes = propTypes;
return EditTagModalComp;
};
export default EditTagModal;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Tag.scss';
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
import './Tag.scss';
const propTypes = {
text: PropTypes.string,
@@ -17,12 +17,12 @@ const Tag = ({
children,
clearable,
colorGenerator,
onClick = () => {},
onClose = () => {},
onClick,
onClose,
}) => (
<span
className="badge tag"
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children || text}

View File

@@ -1,6 +1,5 @@
.tag {
color: #fff;
cursor: pointer;
}
.tag:not(:last-child) {

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types';
import Autosuggest from 'react-autosuggest';
@@ -6,28 +6,23 @@ import { identity } from 'ramda';
import TagBullet from './TagBullet';
import './TagsSelector.scss';
const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
listTags: PropTypes.func,
placeholder: PropTypes.string,
tagsList: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
}),
};
static defaultProps = {
placeholder: 'Add tags to the URL',
};
const propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
listTags: PropTypes.func,
placeholder: PropTypes.string,
tagsList: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
}),
};
componentDidMount() {
const { listTags } = this.props;
const TagsSelector = (colorGenerator) => {
const TagsSelectorComp = ({ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }) => {
useEffect(() => {
listTags();
}, []);
listTags();
}
render() {
const { tags, onChange, placeholder, tagsList } = this.props;
// eslint-disable-next-line
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)}
@@ -40,7 +35,6 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
};
// eslint-disable-next-line no-extra-parens
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
const inputLength = inputValue.length;
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
@@ -75,13 +69,16 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
onlyUnique
renderTag={renderTag}
renderInput={renderAutocompleteInput}
// FIXME Workaround to be able to add tags on Android
addOnBlur
onChange={onChange}
/>
);
}
};
TagsSelectorComp.propTypes = propTypes;
return TagsSelectorComp;
};
export default TagsSelector;

View File

@@ -26,8 +26,7 @@ export default handleActions({
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
dispatch({ type: DELETE_TAG_START });
const { deleteTags } = await buildShlinkApiClient(getState);
const { deleteTags } = buildShlinkApiClient(getState);
try {
await deleteTags([ tag ]);

View File

@@ -31,8 +31,7 @@ export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newNa
getState
) => {
dispatch({ type: EDIT_TAG_START });
const { editTag } = await buildShlinkApiClient(getState);
const { editTag } = buildShlinkApiClient(getState);
try {
await editTag(oldName, newName);

View File

@@ -1,5 +1,7 @@
import { handleActions } from 'redux-actions';
import { isEmpty, reject } from 'ramda';
import PropTypes from 'prop-types';
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
@@ -10,20 +12,46 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements */
const TagStatsType = PropTypes.shape({
shortUrlsCount: PropTypes.number,
visitsCount: PropTypes.number,
});
export const TagsListType = PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
filteredTags: PropTypes.arrayOf(PropTypes.string),
stats: PropTypes.objectOf(TagStatsType), // Record
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = {
tags: [],
filteredTags: [],
stats: {},
loading: false,
error: false,
};
const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag;
const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags);
const increaseVisitsForTags = (tags, stats) => tags.reduce((stats, tag) => {
if (!stats[tag]) {
return stats;
}
const tagStats = stats[tag];
tagStats.visitsCount = tagStats.visitsCount + 1;
stats[tag] = tagStats;
return stats;
}, { ...stats });
export default handleActions({
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[LIST_TAGS]: (state, { tags }) => ({ tags, filteredTags: tags, loading: false, error: false }),
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
[LIST_TAGS_ERROR]: () => ({ ...initialState, error: true }),
[LIST_TAGS]: (state, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
[TAG_DELETED]: (state, { tag }) => ({
...state,
tags: rejectTag(state.tags, tag),
@@ -38,6 +66,10 @@ export default handleActions({
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
}),
[CREATE_VISIT]: (state, { shortUrl }) => ({
...state,
stats: increaseVisitsForTags(shortUrl.tags, state.stats),
}),
}, initialState);
export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => {
@@ -50,16 +82,18 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
dispatch({ type: LIST_TAGS_START });
try {
const { listTags } = await buildShlinkApiClient(getState);
const tags = await listTags();
const { listTags } = buildShlinkApiClient(getState);
const { tags, stats = [] } = await listTags();
const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => {
acc[tag] = { shortUrlsCount, visitsCount };
dispatch({ tags, type: LIST_TAGS });
return acc;
}, {});
dispatch({ tags, stats: processedStats, type: LIST_TAGS });
} catch (e) {
dispatch({ type: LIST_TAGS_ERROR });
}
};
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,
searchTerm,
});
export const filterTags = (searchTerm) => ({ type: FILTER_TAGS, searchTerm });

View File

@@ -12,7 +12,14 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory(
'TagCard',
TagCard,
'DeleteTagConfirmModal',
'EditTagModal',
'ForServerVersion',
'ColorGenerator'
);
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
@@ -21,7 +28,10 @@ const provideServices = (bottle, connect) => {
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
bottle.decorator('TagsList', connect(
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
[ 'forceListTags', 'filterTags', 'createNewVisit', 'loadMercureInfo' ]
));
// Actions
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);

View File

@@ -12,6 +12,7 @@ const propTypes = {
isClearable: PropTypes.bool,
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
ref: PropTypes.object,
disabled: PropTypes.bool,
};
const DateInput = (props) => {

View File

@@ -7,6 +7,9 @@
.date-input-container__input {
padding-right: 35px !important;
}
.date-input-container__input:not(:disabled) {
background-color: #fff !important;
}

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