Compare commits

...

493 Commits

Author SHA1 Message Date
Alejandro Celaya
552169ee77 Merge pull request #553 from shlinkio/develop
Release 3.5.0
2022-01-01 12:50:47 +01:00
Alejandro Celaya
4f03ab18e5 Fixed typo in CHANGELOG 2022-01-01 12:47:54 +01:00
Alejandro Celaya
184d5d97e7 Merge pull request #552 from acelaya-forks/feature/duplicated-servers
Feature/duplicated servers
2022-01-01 12:43:31 +01:00
Alejandro Celaya
ba667a0768 Updated changelog 2022-01-01 12:38:00 +01:00
Alejandro Celaya
15b3424d7f Improved DuplicatedServersModal test 2022-01-01 12:35:06 +01:00
Alejandro Celaya
98398a048b Added logic to detect duplicated servers when importing a servers list 2022-01-01 12:20:09 +01:00
Alejandro Celaya
3cb066f5f5 Reduced unnecesary lines in test 2022-01-01 09:46:21 +01:00
Alejandro Celaya
053b38bee3 Created DuplicatedServerModal test 2022-01-01 09:46:21 +01:00
Alejandro Celaya
1f9356cc21 Created modal to warn when creating a duplicated server 2022-01-01 09:46:21 +01:00
Alejandro Celaya
f07e7fd31c Simplified server-related styles and removed default export from NoMenuLayout 2022-01-01 09:46:21 +01:00
Alejandro Celaya
7794876d7c Merge pull request #551 from acelaya-forks/feature/white-screen
Ensured settings migration function does not crash if settings are no…
2022-01-01 09:45:10 +01:00
Alejandro Celaya
e77b4d7a82 Ensured settings migration function does not crash if settings are not set 2022-01-01 09:40:26 +01:00
Alejandro Celaya
af0d2d3cdc Merge pull request #550 from acelaya-forks/feature/domain-health-checks
Feature/domain health checks
2021-12-28 23:33:33 +01:00
Alejandro Celaya
7e132be686 Fixed DomainStatusIcon test 2021-12-28 23:22:55 +01:00
Alejandro Celaya
aba1972d0d Added dynamic tooltip placement in DomainStatusIcon based on media query 2021-12-28 23:15:34 +01:00
Alejandro Celaya
0268bb6930 Improved icon used for failing status domains 2021-12-28 22:54:17 +01:00
Alejandro Celaya
ecd6e6a066 Created DomainStatusIcon test 2021-12-28 22:48:35 +01:00
Alejandro Celaya
6411c6169b Added tooltips to domain icons 2021-12-27 22:27:13 +01:00
Alejandro Celaya
a78467065a Added logic in ManageDomains and DomainRow components to check if the domains status 2021-12-26 13:53:17 +01:00
Alejandro Celaya
c05c74f009 Extended domainsList reducer, adding functionality to verify domains statuses 2021-12-26 13:38:17 +01:00
Alejandro Celaya
ace29ca4a4 Created helper function to replace the authority on a URL 2021-12-26 13:21:09 +01:00
Alejandro Celaya
4f90d147a4 Merge pull request #548 from acelaya-forks/feature/remove-extra-check
Removed error check which is no longer needed for currently supported…
2021-12-26 10:45:52 +01:00
Alejandro Celaya
9348f211f0 Removed error check which is no longer needed for currently supported Shlink versions 2021-12-26 10:42:25 +01:00
Alejandro Celaya
729d9e4a39 Merge pull request #546 from acelaya-forks/feature/order-by-to-query
Feature/order by to query
2021-12-25 20:04:40 +01:00
Alejandro Celaya
3274088b54 Added tests for new ordering helper functions and updated changelog 2021-12-25 19:58:54 +01:00
Alejandro Celaya
49c841ca07 Added short URLs orderBy handling to the query state 2021-12-25 19:51:25 +01:00
Alejandro Celaya
91f319df65 Merge pull request #545 from acelaya-forks/feature/refactorings
Feature/refactorings
2021-12-25 10:53:44 +01:00
Alejandro Celaya
dbf4b0926e Added Settings suffix to all settings sub-components 2021-12-25 10:49:12 +01:00
Alejandro Celaya
994f31b7e5 Renamed SortingDropdown to OrderingDropdown, for consistency 2021-12-25 10:32:33 +01:00
Alejandro Celaya
6213067f35 Removed default export in SortingDropdown 2021-12-25 10:26:38 +01:00
Alejandro Celaya
76fb45c97e Renamed constants holding orderable fields for short URLs and tags 2021-12-25 10:24:37 +01:00
Alejandro Celaya
2bf5f276f5 Merge pull request #544 from acelaya-forks/feature/ordering-settings
Feature/ordering settings
2021-12-24 15:21:04 +01:00
Alejandro Celaya
eaadd6f7af Removed params param when dispatching list short RULs action, as it was used by a reducer that has been deleted 2021-12-24 15:05:15 +01:00
Alejandro Celaya
86c6acb7b8 Updated changelog 2021-12-24 14:16:42 +01:00
Alejandro Celaya
de32d899bc Added new settings card to customize short URLs lists 2021-12-24 14:15:28 +01:00
Alejandro Celaya
d4356ba6e6 Moved types from old shortUrlsListParams reducer, to the data index file 2021-12-24 13:47:27 +01:00
Alejandro Celaya
275aee4de2 Removed shortUrlsListParams reducer, as the state is now handled internally in the component 2021-12-24 13:39:57 +01:00
Alejandro Celaya
57075c581d Updated Short URLs list so that it allows setting default orderBy from settings 2021-12-24 13:14:13 +01:00
Alejandro Celaya
d8442e435d Added option to customize ordering in tags list 2021-12-24 11:06:02 +01:00
Alejandro Celaya
e954a860bf Added test for migrateDeprecatedSettings function 2021-12-23 17:59:18 +01:00
Alejandro Celaya
5598fe0f53 Created new settings card for tags-related options 2021-12-23 17:53:14 +01:00
Alejandro Celaya
e77508edcc Merge pull request #541 from acelaya-forks/feature/not-empty-resultsets
Feature/not empty resultsets
2021-12-23 10:57:45 +01:00
Alejandro Celaya
c517c0521c Renamed doFallbackRange to doIntervalFallback to make it more descriptive 2021-12-23 10:51:13 +01:00
Alejandro Celaya
e22856ff74 Added logic in reducers to fallback to a different date interval if default one returns no visits 2021-12-23 10:38:02 +01:00
Alejandro Celaya
a30687e4ea Updated changelog 2021-12-22 20:34:56 +01:00
Alejandro Celaya
64ba346566 Updated VisitsStats components to react to the fallbackInterval 2021-12-22 20:23:26 +01:00
Alejandro Celaya
3745b297db Updated visits components to support the doFallbackRange flag 2021-12-22 20:19:54 +01:00
Alejandro Celaya
401418c049 Extended DateRangeSelector to allow updating its value via props after rendering 2021-12-22 20:14:26 +01:00
Alejandro Celaya
7adb40489d Added some helper function to deal with dates 2021-12-22 20:08:28 +01:00
Alejandro Celaya
482314b9f4 Merge pull request #540 from acelaya-forks/feature/extended-basic-creation-form
Added custom slug field to the basic creation form in Overview page
2021-12-19 12:56:21 +01:00
Alejandro Celaya
138e40315d Added custom slug field to the basic creation form in Overview page 2021-12-19 12:52:49 +01:00
Alejandro Celaya
7d6afd47b1 Removed unecesary check 2021-12-14 23:12:39 +01:00
Alejandro Celaya
ed1f650fc6 Merge pull request #539 from acelaya-forks/feature/dash-order-by
Switched to the <field>-<dir> notation in orderBy param for short URL…
2021-12-14 23:09:36 +01:00
Alejandro Celaya
17e4e06fcc Switched to the <field>-<dir> notation in orderBy param for short URLs list 2021-12-14 23:02:16 +01:00
Alejandro Celaya
654b36ab08 Merge pull request #536 from acelaya-forks/feature/default-domain-edition
Feature/default domain edition
2021-12-09 13:49:14 +01:00
Alejandro Celaya
9abbfc5b1e Updated changelog 2021-12-09 13:45:24 +01:00
Alejandro Celaya
c9d906316f Updated domain components to use defaultRedirects prop when present (Shlink 2.10 or newer) 2021-12-09 13:44:29 +01:00
Alejandro Celaya
8d476e0729 Added support to fetch full response from list domains endpoint 2021-12-09 13:16:28 +01:00
Alejandro Celaya
7a320c9574 Merge branch 'develop' of github.com:acelaya/shlink-web-client-react into develop 2021-12-09 13:08:50 +01:00
Alejandro Celaya
3f1392ce62 Fixed changelog 2021-12-09 13:08:19 +01:00
Alejandro Celaya
79e54ea230 Updated changelog 2021-12-08 08:53:10 +01:00
Alejandro Celaya
e2473207ba Merge pull request #534 from shlinkio/dependabot/npm_and_yarn/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2
2021-12-08 08:50:53 +01:00
dependabot[bot]
fb961dd47b Bump axios from 0.21.1 to 0.21.2
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-07 23:57:30 +00:00
Alejandro Celaya
ff1821666e Merge pull request #533 from shlinkio/develop
Release 3.4.2
2021-12-07 20:52:41 +01:00
Alejandro Celaya
9a62bcd8fb Merge branch 'develop' of github.com:shlinkio/shlink-web-client into develop 2021-12-07 20:51:45 +01:00
Alejandro Celaya
9c6c1b43c8 Merge pull request #532 from acelaya-forks/feature/invalid-selector-fix
Feature/invalid selector fix
2021-12-07 20:48:11 +01:00
Alejandro Celaya
4986dbcb91 Fixed tests 2021-12-07 20:41:07 +01:00
Alejandro Celaya
527d4acf17 Updated changelog 2021-12-07 20:32:48 +01:00
Alejandro Celaya
0237253caf Fixed crash in domains when using a domain with port as element ID 2021-12-07 20:24:59 +01:00
Alejandro Celaya
47f5f47867 Merge pull request #529 from shlinkio/develop
Release 3.4.1
2021-11-20 10:11:57 +01:00
Alejandro Celaya
70d4572797 Merge pull request #528 from acelaya-forks/feature/adr
Added first Architecture Decision Records
2021-11-20 10:07:29 +01:00
Alejandro Celaya
8bfa14386b Added first Architecture Decision Records 2021-11-20 10:06:43 +01:00
Alejandro Celaya
9f6401c30b Merge pull request #527 from acelaya-forks/feature/omit-auto-connect-on-export
Fixed export servers to ensure autoConnect is not included
2021-11-20 09:50:37 +01:00
Alejandro Celaya
14b2ee53b5 Fixed export servers to ensure autoConnect is not included 2021-11-20 09:44:12 +01:00
Alejandro Celaya
7db9974e8d Merge pull request #523 from acelaya-forks/feature/home-alignment
Updated landing page to be vertically aligned on mobile devices
2021-11-13 23:08:44 +01:00
Alejandro Celaya
7d29129ca1 Updated landing page to be vertically aligned on mobile devices 2021-11-13 23:04:59 +01:00
Alejandro Celaya
42152c6872 Merge pull request #519 from shlinkio/develop
Release 3.4.0
2021-11-11 21:44:39 +01:00
Alejandro Celaya
b7e9afd54a Added v3.4.0 to changelog 2021-11-11 21:42:58 +01:00
Alejandro Celaya
3bc9bd2ef8 Merge pull request #517 from acelaya-forks/feature/reset-page
Feature/reset page
2021-11-11 21:42:27 +01:00
Alejandro Celaya
7bc3819ebe Fixed TS error in SearchBar test 2021-11-11 21:38:37 +01:00
Alejandro Celaya
0642443aa9 Updated changelog 2021-11-11 21:32:28 +01:00
Alejandro Celaya
2e77cd1969 Removed handling of most short URLs list params from a reducer 2021-11-11 21:28:17 +01:00
Alejandro Celaya
21b8e05e35 Moved dates handling in short URLs list to query 2021-11-10 22:25:56 +01:00
Alejandro Celaya
ed038b9799 Fixed ShortUrlsList test 2021-11-08 23:41:17 +01:00
Alejandro Celaya
5f33059de1 Improved SearchBar test 2021-11-08 23:23:45 +01:00
Alejandro Celaya
3bc5b4c154 Extended ShortUrlsPaginator so that it allows appending current query string 2021-11-08 22:13:37 +01:00
Alejandro Celaya
a2421ee2d3 Created helper function to evolve a query string based on an object 2021-11-07 11:22:29 +01:00
Alejandro Celaya
109baef828 Minor changes on tags filtering for short URLs 2021-11-07 11:03:31 +01:00
Alejandro Celaya
303900756d Added TableOrderIcon test 2021-11-06 22:46:40 +01:00
Alejandro Celaya
fe81e023e8 Moved table sorting icon to its own component wrapping the logic 2021-11-06 22:34:29 +01:00
Alejandro Celaya
5906921eec Merge pull request #516 from acelaya-forks/feature/consistent-tags-sorting
Feature/consistent tags sorting
2021-11-06 12:36:19 +01:00
Alejandro Celaya
ee826458be Updated sorting dropdown to accept an order object instead of two individual props 2021-11-06 12:26:20 +01:00
Alejandro Celaya
7169c6e083 Updated changelog 2021-11-06 12:05:49 +01:00
Alejandro Celaya
0bb5c7d8af Simplified branches while resolving server Id 2021-11-06 12:04:26 +01:00
Alejandro Celaya
a6892b8a12 Covered ordering use cases on TagsList test 2021-11-06 11:58:59 +01:00
Alejandro Celaya
765c4713a2 Fixed all tests to work with new tags sorting approach 2021-11-06 11:30:42 +01:00
Alejandro Celaya
e6737ff1f2 Moved logic to render sorting icon to tags list, as it's too specific 2021-11-06 11:11:09 +01:00
Alejandro Celaya
7a2d0e5dee Added sorting dropdown for tags, that can be used regardless the display mode 2021-11-06 11:03:56 +01:00
Alejandro Celaya
daf076a57e Moved logic to sort tags to TagsList component, to allow sorting on any context 2021-11-06 10:55:01 +01:00
Alejandro Celaya
af08b53002 Merge pull request #514 from acelaya-forks/feature/sticky-tags-header
Feature/sticky tags header
2021-11-01 14:16:00 +01:00
Alejandro Celaya
39d5853fe3 Added tests for ordering logic in TagsTable 2021-11-01 14:10:57 +01:00
Alejandro Celaya
9cbeef1cb4 Moved test to the right place 2021-11-01 13:57:53 +01:00
Alejandro Celaya
2857e59273 Updated changelog 2021-11-01 13:45:30 +01:00
Alejandro Celaya
04571ea634 Added logic to order tags list 2021-11-01 13:42:53 +01:00
Alejandro Celaya
5241925acc Added not-enabled sorting on tags table 2021-11-01 12:48:11 +01:00
Alejandro Celaya
844cf51d04 Added missing prettify on number of visits to export and selected visits 2021-11-01 11:19:20 +01:00
Alejandro Celaya
b0c1549005 Added sticky header to tags table 2021-11-01 11:13:51 +01:00
Alejandro Celaya
16d2e437b6 Merge pull request #513 from acelaya-forks/feature/update-test-deps
Feature/update test deps
2021-10-31 12:44:22 +01:00
Alejandro Celaya
944b166e43 Added explicit any type on caught errors where needed 2021-10-31 12:38:42 +01:00
Alejandro Celaya
e5f99d0893 Removed remaining instances of setImmediate in tests 2021-10-31 12:33:17 +01:00
Alejandro Celaya
57e73dcba6 Fixed unhandled promise in remoteServers.test 2021-10-31 12:20:02 +01:00
Alejandro Celaya
80f0f9bd08 Updated test libs 2021-10-31 12:07:38 +01:00
Alejandro Celaya
1486d1fba5 Updated enzyme deps 2021-10-31 10:34:10 +01:00
Alejandro Celaya
e28f74169d Merge pull request #512 from acelaya-forks/feature/server-auto-connect
Feature/server auto connect
2021-10-31 00:16:54 +02:00
Alejandro Celaya
2375882c73 Fixed TS compile error in Home component test 2021-10-31 00:12:03 +02:00
Alejandro Celaya
7b344998ea Updated changelog 2021-10-31 00:08:47 +02:00
Alejandro Celaya
e8ea3b4abe Updated to node 16 and allowed to auto-connect to the first server marked as auto-connect 2021-10-31 00:07:38 +02:00
Alejandro Celaya
bd0fca23cf Merge pull request #511 from acelaya-forks/feature/push-visits-in-date
Feature/push visits in date
2021-10-24 22:44:52 +02:00
Alejandro Celaya
6d392ba403 Added more tests covering how real-time visits are filtered out based on date intervals 2021-10-24 22:37:14 +02:00
Alejandro Celaya
e135dd92ec Ensured new visits are pushed to the state only if they match selected date range 2021-10-24 10:31:32 +02:00
Alejandro Celaya
36af3c3dd0 Merge pull request #510 from acelaya-forks/feature/improved-servers-management
Feature/improved servers management
2021-10-23 11:43:11 +02:00
Alejandro Celaya
c0e33d6a6a Updated changelog 2021-10-23 11:34:39 +02:00
Alejandro Celaya
41398f659e Created ManageServers test 2021-10-23 11:33:32 +02:00
Alejandro Celaya
8618519b6b Created ManageServersRowDropdown test 2021-10-23 10:55:52 +02:00
Alejandro Celaya
c7c32b494e Created ManageServersRow test 2021-10-23 10:34:20 +02:00
Alejandro Celaya
ec9fd67b8a Extracted ManageServersRowDropdown to its own component 2021-10-22 20:26:11 +02:00
Alejandro Celaya
7637ce3107 Added logic to toggle auto-connect on servers 2021-10-22 20:13:23 +02:00
Alejandro Celaya
ada5488a6c Ensured export servers btn is not displayed when there are no servers 2021-10-22 19:03:12 +02:00
Alejandro Celaya
478209f50d Improvements on ManageServers 2021-10-22 18:53:00 +02:00
Alejandro Celaya
7f4263966e Created new section to manage servers 2021-10-17 19:13:06 +02:00
Alejandro Celaya
002f280364 Extracted common dropdown-item style 2021-10-17 18:58:38 +02:00
Alejandro Celaya
d8a6676d30 Merge pull request #507 from acelaya-forks/feature/code-coverage
Increased minimum required branch code coverage to 80
2021-10-17 13:47:30 +02:00
Alejandro Celaya
beff6668de Increased minimum required branch code coverage to 80 2021-10-17 13:43:37 +02:00
Alejandro Celaya
4baa901f1c Fixed merge conflicts 2021-10-17 12:41:23 +02:00
Alejandro Celaya
f19746cd58 Merge pull request #505 from acelaya-forks/feature/resettable-title
Ensured short URL title can be resetted after creation
2021-10-17 12:39:45 +02:00
Alejandro Celaya
85161915b1 Ensured short URL title can be resetted after creation 2021-10-17 12:35:11 +02:00
Alejandro Celaya
29bf53bf88 Ensured CI is run on develop branch 2021-10-16 18:12:18 +02:00
Alejandro Celaya
d2284cd181 Merge pull request #504 from acelaya-forks/feature/reusable-workflow
Replaced local ci workflow with one from external repo
2021-10-16 18:10:55 +02:00
Alejandro Celaya
88305a57bf Replaced local ci workflow with one from external repo 2021-10-16 18:05:06 +02:00
Alejandro Celaya
f4908cacc3 Merge pull request #502 from acelaya-forks/feature/forward-query
Feature/forward query
2021-10-13 23:17:52 +02:00
Alejandro Celaya
2925752fde Updated changelog 2021-10-13 23:11:17 +02:00
Alejandro Celaya
1bf3569774 Allowed to customize initial state for forward query 2021-10-13 23:10:22 +02:00
Alejandro Celaya
9e6907deb4 Added forward query component to short URL form 2021-10-13 22:50:48 +02:00
Alejandro Celaya
eaa6efe803 Merge pull request #501 from acelaya-forks/feature/allow-all-default
Feature/allow all default
2021-10-03 21:18:37 +02:00
Alejandro Celaya
d38020e2d1 Updated changelog 2021-10-03 21:12:06 +02:00
Alejandro Celaya
4c1d285d04 Ensured the 'all' item is selected when custom date ranges are unselected 2021-10-03 21:09:48 +02:00
Alejandro Celaya
c71e0919e9 Allowed to select 'all' as the default interval for visits 2021-10-03 21:07:07 +02:00
Alejandro Celaya
a295734c13 Merge pull request #499 from shlinkio/develop
Release 3.3.1
2021-09-27 22:57:46 +02:00
Alejandro Celaya
d00b6165b3 Merge pull request #498 from acelaya-forks/feature/fix-multi-dots
Ensured all dots are replaced from domain when generating its domain ID
2021-09-27 22:54:06 +02:00
Alejandro Celaya
0cbba1182f Ensured all dots are replaced from domain when generating its domain ID 2021-09-27 22:50:12 +02:00
Alejandro Celaya
785806b7a1 Merge pull request #495 from shlinkio/develop
Release 3.3.0
2021-09-25 11:59:18 +02:00
Alejandro Celaya
15b7fd5c93 Merge pull request #494 from acelaya-forks/feature/remove-old-shlink
Feature/remove old shlink
2021-09-25 11:54:26 +02:00
Alejandro Celaya
9b32bd2817 Updated changelog 2021-09-25 11:48:25 +02:00
Alejandro Celaya
8b5b035568 Removed rest of version checks for versions older than 2.4 2021-09-25 11:47:18 +02:00
Alejandro Celaya
f7cc90bb77 Removed some version checks for versions older than 2.4.0 2021-09-25 11:40:16 +02:00
Alejandro Celaya
7b0cda7191 Merge pull request #493 from acelaya-forks/feature/tags-list
Feature/tags list
2021-09-25 11:03:13 +02:00
Alejandro Celaya
9791486341 Created TagsTableRow test 2021-09-25 10:57:42 +02:00
Alejandro Celaya
40ef51a348 Created TagsTable test 2021-09-25 10:37:13 +02:00
Alejandro Celaya
a90287ed02 Updated changelog 2021-09-25 10:07:42 +02:00
Alejandro Celaya
12f6a132bd Added pagination to tags table 2021-09-25 09:34:38 +02:00
Alejandro Celaya
1da7119c5c Added new setting to determine default display mode for tags 2021-09-25 08:20:56 +02:00
Alejandro Celaya
01f6f11ee2 Created new tags components 2021-09-24 20:25:28 +02:00
Alejandro Celaya
57d4db5daa Created TagsModeDropdown component 2021-09-24 20:21:02 +02:00
Alejandro Celaya
c7559e78a2 Created DropdownBtnMenu test 2021-09-24 20:04:16 +02:00
Alejandro Celaya
2f76c5381f Added some tests for new tags components 2021-09-24 19:55:26 +02:00
Alejandro Celaya
304a7431ad Created new component to handle buttons displaying a dropdown menu 2021-09-24 19:10:03 +02:00
Alejandro Celaya
691dabcfbc Merge pull request #489 from acelaya-forks/feature/coverage-80
Feature/coverage 80
2021-09-20 22:13:18 +02:00
Alejandro Celaya
2dd35dcd44 Fixed import statement order 2021-09-20 22:05:24 +02:00
Alejandro Celaya
44930b8c5f Replaced usages of test with it, and updated changelog 2021-09-20 22:00:34 +02:00
Alejandro Celaya
310913b222 Adjusted required code coverage to current values 2021-09-20 21:52:46 +02:00
Alejandro Celaya
b877aa8e5b Improved branching coverage in some parts 2021-09-20 21:51:51 +02:00
Alejandro Celaya
27e3d65143 Created PaginationDropdown test 2021-09-20 21:31:14 +02:00
Alejandro Celaya
b462169e1e Created missing tests for settings components 2021-09-20 21:23:39 +02:00
Alejandro Celaya
dc2f30c73b Recovered test 2021-09-20 20:52:57 +02:00
Alejandro Celaya
8df1ba4671 Added toApiParams test 2021-09-20 20:48:52 +02:00
Alejandro Celaya
56a3dbd07f Created EditTagModal test 2021-09-20 20:32:54 +02:00
Alejandro Celaya
856ee6d65c Created MainHeader test 2021-09-19 11:17:56 +02:00
Alejandro Celaya
9518a5e442 Created ImageDownloader test 2021-09-19 10:57:36 +02:00
Alejandro Celaya
3a8c7a7bf4 Added required code coverage for jest 2021-09-19 10:31:53 +02:00
Alejandro Celaya
7fb0658349 Merge pull request #488 from acelaya-forks/feature/split-charts
Feature/split charts
2021-09-18 19:18:34 +02:00
Alejandro Celaya
6d79851d18 Removed last reference to graph instead of chart 2021-09-18 19:09:31 +02:00
Alejandro Celaya
f89e4244ea Moved LineChartCard with the rest of the charts 2021-09-18 19:07:50 +02:00
Alejandro Celaya
3c23016028 Refactored components used to render charts for visits 2021-09-18 19:05:28 +02:00
Alejandro Celaya
27c4bd792b Merge pull request #487 from acelaya-forks/feature/chartjs-3
Feature/chartjs 3
2021-09-18 13:26:41 +02:00
Alejandro Celaya
1b158b3df4 Fixed links to issues defined as PRs in changelog 2021-09-18 13:22:11 +02:00
Alejandro Celaya
19f0dc2920 Updated changelog 2021-09-18 13:18:48 +02:00
Alejandro Celaya
a15917b1ae Fixed tests 2021-09-18 13:17:04 +02:00
Alejandro Celaya
7e5397dd38 Created PieChartLegend test 2021-09-18 12:59:54 +02:00
Alejandro Celaya
382d7b1c9f Improved comment 2021-09-18 12:34:14 +02:00
Alejandro Celaya
58ee123cef Memoized DefaultChart to make sure it does not change unless its props also change 2021-09-18 12:29:15 +02:00
Alejandro Celaya
039a56f410 Fixed tooltips in bar charts 2021-09-18 12:07:05 +02:00
Alejandro Celaya
6780aa623b Merged develop 2021-09-12 12:28:01 +02:00
Alejandro Celaya
7752140c9d Fixed merge conflicts 2021-09-12 10:33:10 +02:00
Alejandro Celaya
8bfd38d861 Merge pull request #485 from shlinkio/hotfix/v3.2.1
Release v3.2.1
2021-09-12 10:30:10 +02:00
Alejandro Celaya
27b6676edc Merge pull request #484 from acelaya-forks/feature/wrong-end-dates
ClFeature/wrong end dates
2021-09-12 10:22:30 +02:00
Alejandro Celaya
66c91722fc Updated changelog 2021-09-12 10:17:24 +02:00
Alejandro Celaya
178f15b7d3 Ensured end dates are set at the end of the date when filtering visits 2021-09-12 10:16:05 +02:00
Alejandro Celaya
0e47f9b502 Merge pull request #483 from acelaya-forks/feature/import-servers-win
Feature/import servers win
2021-09-12 10:04:33 +02:00
Alejandro Celaya
d2ad1cd54b Updated changelog 2021-09-12 09:58:24 +02:00
Alejandro Celaya
91e003153b Updated logic to import servers, to not check the file type 2021-09-12 09:56:53 +02:00
Alejandro Celaya
c6cca9c91f Fixed indentation 2021-09-12 09:56:29 +02:00
Alejandro Celaya
7330fd85ff Updated function signaure 2021-09-12 09:34:51 +02:00
Alejandro Celaya
b61d863356 Fixed merge conflicts 2021-09-12 09:03:31 +02:00
Alejandro Celaya
f54460e8f8 First attempt to fix click event on charts 2021-09-05 16:51:18 +02:00
Alejandro Celaya
036c8aafcb Extracted PieChartLegend to its own component 2021-09-05 16:51:18 +02:00
Alejandro Celaya
d55160e8f6 Recovered function to render pie chart labels 2021-09-05 16:51:18 +02:00
Alejandro Celaya
0572bc2854 First iteration to migrate to Chart.js 3. Making it compile 2021-09-05 16:51:18 +02:00
Alejandro Celaya
aceb2350cf Merge pull request #479 from acelaya-forks/feature/tag-special-chars
Fixed tags including special chars being broken when used in URLs
2021-09-01 10:59:45 +02:00
Alejandro Celaya
923575b38b Fixed tags including special chars being broken when used in URLs 2021-09-01 10:54:33 +02:00
Alejandro Celaya
f41a8473f8 Fixed path on preview env 2021-08-29 11:31:33 +02:00
Alejandro Celaya
b94cdb2680 Fixed action version 2021-08-29 10:45:30 +02:00
Alejandro Celaya
0cdae72ebd Migrated to external deploy-preview action 2021-08-29 10:33:55 +02:00
Alejandro Celaya
75931edc33 Merge pull request #473 from acelaya-forks/feature/manage-domains
Feature/manage domains
2021-08-24 20:31:38 +02:00
Alejandro Celaya
d1fcd10c04 Fixed TS errors in tests 2021-08-24 20:26:57 +02:00
Alejandro Celaya
06f4cff97e Fixed missing initial values when editing one domain redirects 2021-08-24 20:24:34 +02:00
Alejandro Celaya
0804322a9f Updated changelog 2021-08-24 20:15:05 +02:00
Alejandro Celaya
53ba14e6f6 Created ManageDomains test 2021-08-24 20:13:54 +02:00
Alejandro Celaya
ead5f2033b Created DomainRow test 2021-08-24 19:53:28 +02:00
Alejandro Celaya
74ac122787 Created EditDomainRedirectsModal test 2021-08-23 19:12:41 +02:00
Alejandro Celaya
13785c7beb Removed styles from one section that ended up in generic component 2021-08-23 18:31:40 +02:00
Alejandro Celaya
9887cae4fd Added InfoTooltip test 2021-08-23 18:26:15 +02:00
Alejandro Celaya
410d372755 Extracted InfoTooltip to its own component 2021-08-22 11:05:07 +02:00
Alejandro Celaya
e7a969a78d Merge branch 'feature/manage-domains' of github.com:acelaya/shlink-web-client-react into feature/manage-domains 2021-08-22 10:47:00 +02:00
Alejandro Celaya
b1d6f58619 Added responsiveness to manage domains table 2021-08-22 10:46:47 +02:00
Alejandro Celaya
f49b74229c Enhanced tooltip 2021-08-22 09:34:56 +02:00
Alejandro Celaya
d88f822125 Extended ShlinkApiClient test covering editDomainRedirects 2021-08-22 09:11:14 +02:00
Alejandro Celaya
dce1cefd49 Created domainRedirects reducer test 2021-08-22 09:06:18 +02:00
Alejandro Celaya
8e71b2e2b1 Improved domainsList reducer test 2021-08-22 09:00:58 +02:00
Alejandro Celaya
69cb3bd619 Implemented logic to edit domain redirects 2021-08-21 17:53:06 +02:00
Alejandro Celaya
bf29158a8a Added missing alignment 2021-08-20 17:31:42 +02:00
Alejandro Celaya
a28a4846bc Created base structure to manage domains 2021-08-20 17:30:07 +02:00
Alejandro Celaya
5eee86003d Merge pull request #471 from acelaya-forks/feature/qr-code-error-correction
Feature/qr code error correction
2021-08-16 17:48:46 +02:00
Alejandro Celaya
37a3a2022b Added missing props on qrCodes test 2021-08-16 17:44:11 +02:00
Alejandro Celaya
c6be8bd96f Created tests for new QR code dropdowns 2021-08-16 17:38:25 +02:00
Alejandro Celaya
5166340779 Extracted some QR code modal components to external components 2021-08-16 17:26:54 +02:00
Alejandro Celaya
520e52595f Updated changelog 2021-08-16 17:14:57 +02:00
Alejandro Celaya
461c0e0bc9 Added new component for QR codes error correction when consuming Shlink 2.8 2021-08-16 17:13:31 +02:00
Alejandro Celaya
0ecb771b23 Created lint:fix global command 2021-08-16 13:21:53 +02:00
Alejandro Celaya
c89e2b5d25 Merge pull request #470 from acelaya-forks/feature/download-qr-code
Feature/download qr code
2021-08-16 13:20:00 +02:00
Alejandro Celaya
aa8f2a0cbc Updated changelog 2021-08-16 13:15:16 +02:00
Alejandro Celaya
eb90aa2274 Added support to download QR codes to the QR code modal 2021-08-16 13:13:41 +02:00
Alejandro Celaya
2b5420a429 Merge pull request #468 from acelaya-forks/feature/tags-global-search
Feature/tags global search
2021-08-15 18:27:16 +02:00
Alejandro Celaya
3484e74559 Fixed coding styles 2021-08-15 18:21:36 +02:00
Alejandro Celaya
edd536cc1e Updated changelog 2021-08-15 18:17:05 +02:00
Alejandro Celaya
322396a366 Allowed to dynamically determine how short URL suggestions are calculated 2021-08-15 18:13:13 +02:00
Alejandro Celaya
9f02bc6496 Added new settings to determine how to search on tags during short URL creation, and how many suggestions to display 2021-08-15 10:58:26 +02:00
Alejandro Celaya
590393dcfd Merge pull request #467 from acelaya-forks/feature/comma-separated-tags
Feature/comma separated tags
2021-08-15 09:54:57 +02:00
Alejandro Celaya
8029823271 Updated changelog 2021-08-15 09:50:27 +02:00
Alejandro Celaya
4417a17d5c Improved TagsSelector component test, covering different logic while adding tags 2021-08-15 09:49:01 +02:00
Alejandro Celaya
b8a7dccf92 Ensured TagsSelector does not allow duplicated tags, and allows adding multiple coma-separated tags at once 2021-08-15 09:45:14 +02:00
Alejandro Celaya
cbe5f98aa3 Merge pull request #466 from acelaya-forks/feature/tags-title
Added dynamic title on hover for tags with a very long title
2021-08-14 19:45:29 +02:00
Alejandro Celaya
6c2f5b99ac Added dynamic title on hover for tags with a very long title 2021-08-14 19:40:53 +02:00
Alejandro Celaya
fa64c950ca Merge pull request #453 from shlinkio/develop
Release 3.2.0
2021-07-12 16:44:20 +02:00
Alejandro Celaya
0e4667e59c Added v3.2.0 to changelog 2021-07-12 16:40:36 +02:00
Alejandro Celaya
56d9dcf562 Merge pull request #452 from acelaya-forks/feature/auto-pwa-restart
Feature/auto pwa restart
2021-07-12 16:39:27 +02:00
Alejandro Celaya
d5e8f81076 Created AppUpdateBanner test 2021-07-12 16:34:58 +02:00
Alejandro Celaya
69905c4b38 Added logic to allow refreshing the PWA without closing the tabs 2021-07-12 16:16:18 +02:00
Alejandro Celaya
08694d7693 Extracted update banner to a separated component 2021-07-12 12:24:04 +02:00
Alejandro Celaya
8045fa8886 Added more improvements to landing page 2021-07-12 12:05:33 +02:00
Alejandro Celaya
0789494a40 Removed deprecated env var for publish release 2021-07-11 22:30:53 +02:00
Alejandro Celaya
34837f2917 Merge pull request #451 from acelaya-forks/feature/improve-landing
Feature/improve landing
2021-07-11 22:29:45 +02:00
Alejandro Celaya
9e8c743d53 Updated changelog 2021-07-11 22:26:11 +02:00
Alejandro Celaya
239cc4ab84 Improved landing page design 2021-07-11 22:25:36 +02:00
Alejandro Celaya
b3e79f4219 Merge pull request #447 from acelaya-forks/feature/visits-filter-reducer
Feature/visits filter reducer
2021-07-02 20:10:36 +02:00
Alejandro Celaya
7c11a6d1ab Allowed to deselect orphan visits type 2021-07-02 20:05:51 +02:00
Alejandro Celaya
635ee6c5eb Updated changelog 2021-07-02 19:57:25 +02:00
Alejandro Celaya
f79bd39de7 Moved logic to filter visits to reducers 2021-06-30 03:23:45 +02:00
Alejandro Celaya
5c6979122d Extracted VisitsFilter type from component for general usage 2021-06-30 02:36:13 +02:00
Alejandro Celaya
402efac12e Merge pull request #446 from acelaya-forks/feature/tags-input
Feature/tags input
2021-06-26 17:50:12 +02:00
Alejandro Celaya
770ba624c2 Created TagsSelector test 2021-06-26 17:44:26 +02:00
Alejandro Celaya
d4236b914d Updated changelog 2021-06-26 17:07:59 +02:00
Alejandro Celaya
2cc92b5b41 Ensured tags are added onBlur 2021-06-26 17:06:39 +02:00
Alejandro Celaya
f0598ba47f Changed min query length for tags input to 1 2021-06-26 10:44:17 +02:00
Alejandro Celaya
66c5c7ebf1 Replaced tags component by one which is better maintained 2021-06-26 10:17:07 +02:00
Alejandro Celaya
741bc21a55 Merge pull request #445 from acelaya-forks/feature/moment-js-migration
Feature/moment js migration
2021-06-25 20:09:12 +02:00
Alejandro Celaya
fb1ced5e3f Created test for Time component 2021-06-25 20:05:06 +02:00
Alejandro Celaya
3999d14bab Created abstraction function to parse dates 2021-06-25 19:52:50 +02:00
Alejandro Celaya
99c77622cd Updated changelog 2021-06-25 19:41:25 +02:00
Alejandro Celaya
bc5c25deb0 Fixed issue due to immutability 2021-06-25 19:33:18 +02:00
Alejandro Celaya
0275908f69 Removed last references to moment.js from the project 2021-06-25 19:15:19 +02:00
Alejandro Celaya
4be1a295d8 Replaced most of the usages of moment with date-fns 2021-06-24 20:13:06 +02:00
Alejandro Celaya
ee65c0c050 Merge pull request #444 from acelaya-forks/feature/crawlable-option
Feature/crawlable option
2021-06-23 20:03:37 +02:00
Alejandro Celaya
d718329b52 Updated changelog 2021-06-23 19:59:47 +02:00
Alejandro Celaya
55716a8f7f Created ShortUrlFormCheckboxGroup test 2021-06-23 19:59:06 +02:00
Alejandro Celaya
5ef719c592 Added support to set crawlable short URLs during creation and edition 2021-06-23 19:52:23 +02:00
Alejandro Celaya
3a57416525 Merge pull request #443 from acelaya-forks/feature/visits-filtering
Feature/visits filtering
2021-06-22 21:17:35 +02:00
Alejandro Celaya
5bd57e71fd Improved DropdownBtn test 2021-06-22 21:12:06 +02:00
Alejandro Celaya
c4ed838510 Updated changelog 2021-06-22 21:06:29 +02:00
Alejandro Celaya
affe2309b0 Ensured filter for bots does not show for Shlink older than 2.7.0 2021-06-22 21:03:47 +02:00
Alejandro Celaya
638ce89780 Improved dropdown to filter visits, adding support to filter out bots 2021-06-22 20:46:28 +02:00
Alejandro Celaya
a0ab9533cb Merge pull request #441 from acelaya-forks/feature/bots-support
Feature/bots support
2021-06-13 11:58:58 +02:00
Alejandro Celaya
7b80948eea Fixed TS errors in tests 2021-06-13 11:54:51 +02:00
Alejandro Celaya
1cf96c7212 Improved VisitsTable test 2021-06-13 11:49:53 +02:00
Alejandro Celaya
151175dc70 Updated changelog 2021-06-13 11:41:41 +02:00
Alejandro Celaya
a30376344e Added tests covering visits table with potential bots 2021-06-13 11:38:13 +02:00
Alejandro Celaya
db0c43dcdd Added column to display if a visit is a potential bot in the visits table 2021-06-13 11:07:32 +02:00
Alejandro Celaya
a3550f8e52 Updated docker images 2021-06-13 09:55:07 +02:00
Alejandro Celaya
3a3babadeb Renamed script 2021-06-13 09:51:10 +02:00
Alejandro Celaya
e22ad2c822 Merge pull request #439 from acelaya-forks/feature/fix-horizontal-scroll
Fixed horizontal scroll
2021-06-13 07:59:19 +02:00
Alejandro Celaya
342dda3ec9 Fixed horizontal scroll 2021-06-13 07:52:53 +02:00
Alejandro Celaya
b7af07c043 Fixed docker build script so that it can work with develop branch 2021-06-06 19:27:43 +02:00
Alejandro Celaya
6b338275d3 Updated branch where the docker image builds unstable versions 2021-06-06 19:24:57 +02:00
Alejandro Celaya
a72d3b2720 Updated changelog 2021-06-06 19:14:18 +02:00
Alejandro Celaya
18042dba6e Merge branch 'main' into develop 2021-06-06 19:13:29 +02:00
Alejandro Celaya
6e09d1372f Merge pull request #436 from acelaya-forks/feature/recover-pwa
Feature/recover pwa
2021-06-06 19:11:37 +02:00
Alejandro Celaya
ce02d29ca3 Ensure review environment does not contain a service worker 2021-06-06 19:06:24 +02:00
Alejandro Celaya
e193c700d6 Fixed TS error in App test 2021-06-06 18:58:05 +02:00
Alejandro Celaya
bfeb282aa9 Added appUpdates reducer test 2021-06-06 18:49:38 +02:00
Alejandro Celaya
5caa648112 Added banner to be displayed when the service worker has updated the app in the background 2021-06-06 18:41:10 +02:00
Alejandro Celaya
4546b74b6f Added missing webpack config that generates service worker 2021-06-06 12:54:32 +02:00
Alejandro Celaya
2fb5507803 Added service worker back to the project to recover PWA capabilities 2021-06-06 12:27:02 +02:00
Alejandro Celaya
93329c5a12 erge branch 'develop' of github.com:shlinkio/shlink-web-client into develop 2021-05-30 17:51:16 +02:00
Alejandro Celaya
5a91b668dc Updated changelog 2021-05-30 17:50:54 +02:00
Alejandro Celaya
66aac4771c Merge pull request #434 from matiasgarciaisaia/patch-1
Update server.json alternative Docker configs in README.md
2021-05-29 18:27:27 +02:00
Matías García Isaía
ce04b8eb58 Update server.json alternative Docker configs in README.md
See #432 & #433
2021-05-29 11:11:24 -03:00
Alejandro Celaya
e0c20c704e Merge pull request #432 from matiasgarciaisaia/feature/conf-volume
Support servers.json in a conf.d directory
2021-05-29 11:58:41 +02:00
Alejandro Celaya
d5fadc56af Removed new empty line added by mistake 2021-05-29 11:54:08 +02:00
Alejandro Celaya
bbc3342c00 Moved servers.json config on nginx above another less restrictive but conflicting rule 2021-05-29 11:53:06 +02:00
Matias Garcia Isaia
76ebbd318a Support servers.json in a conf.d directory
In Cattle (and maybe other Docker environments) you can't mount specific files, but
have to mount a whole volume as a directory.

We now allow the servers.json to be looked for inside a specific folder to support
that use case.
2021-05-29 11:41:32 +02:00
Alejandro Celaya
24801b068b Updated changelog 2021-05-29 11:40:14 +02:00
Alejandro Celaya
4c21ad0a89 Merge pull request #433 from matiasgarciaisaia/feature/server-from-env
Single-server servers.json from environment variables in Docker image
2021-05-29 11:35:27 +02:00
Alejandro Celaya
f626f9b046 Renamed env vars 2021-05-29 11:30:35 +02:00
Matias Garcia Isaia
ccffa0fe12 Allow Docker image to generate servers.json from environment
In the Docker image, generate the servers.json with a single server
by reading environment variables.
2021-05-28 22:01:39 -03:00
Alejandro Celaya
d5530b4614 Merge pull request #429 from acelaya-forks/feature/stryker5
Updated to stryker 5
2021-05-15 12:08:26 +02:00
Alejandro Celaya
7c327099bb Updated changelog 2021-05-15 12:04:25 +02:00
Alejandro Celaya
577d7e79da Updated to stryker 5 2021-05-15 12:02:43 +02:00
Alejandro Celaya
31736fad1e Ensured proper ref is checked out on preview env 2021-05-09 21:09:24 +02:00
Alejandro Celaya
6319a81ddb Fixed event name 2021-05-09 21:02:32 +02:00
Alejandro Celaya
0ca6ff6906 Ensured checkout is done from remote remo 2021-05-09 20:52:15 +02:00
Alejandro Celaya
eb69165781 Changed event for preview deployments to use pull_request_target 2021-05-09 14:39:13 +02:00
Alejandro Celaya
4e3d311bef Changed token used for preview deployment 2021-05-09 14:25:10 +02:00
Alejandro Celaya
54b7aeed20 Added github token to preview env deployment 2021-05-09 14:14:56 +02:00
Alejandro Celaya
2ba8db1fd3 Ensured preview envs are generated on PRs only 2021-05-09 14:03:59 +02:00
Alejandro Celaya
f74270a767 Ensured branch slug is generated before building project on preview env deployment 2021-05-09 13:53:36 +02:00
Alejandro Celaya
9a245fbf13 Created new workflow to generate preview envs 2021-05-09 13:34:39 +02:00
Alejandro Celaya
f16e9565e2 Added v3.1.1 to changelog 2021-05-08 11:04:21 +02:00
Alejandro Celaya
e65f9a7b89 Merge pull request #419 from acelaya-forks/feature/edit-feedback
Feature/edit feedback
2021-05-08 11:02:17 +02:00
Alejandro Celaya
0141a1e0ed Updated changelog 2021-05-08 10:57:12 +02:00
Alejandro Celaya
937876ce67 Improved feedback when editing a short URL 2021-05-08 10:56:20 +02:00
Alejandro Celaya
b52120e0d3 Updated changelog 2021-05-07 20:37:41 +02:00
Alejandro Celaya
62b65334b5 Merge pull request #418 from antwonw/patch-1
Update QrCodeModal.tsx
2021-05-07 20:36:25 +02:00
antwonw
76dae535d9 Update QrCodeModal.tsx
Remove indivisible class to fix hyperlink extending outside modal.
2021-05-06 16:56:53 -07:00
Alejandro Celaya
23ba140ff4 Merge pull request #416 from acelaya-forks/feature/prepend-visits
Feature/prepend visits
2021-05-01 16:44:21 +02:00
Alejandro Celaya
76ff7d81b9 Updated changelog 2021-05-01 16:40:22 +02:00
Alejandro Celaya
66deba29f5 Ensured new visits are prepended and not appended, ensuring they keep the proper order 2021-05-01 16:39:13 +02:00
Alejandro Celaya
e44527e9c9 Merge pull request #415 from acelaya-forks/feature/update-url-on-list
Feature/update url on list
2021-04-24 18:06:02 +02:00
Alejandro Celaya
aec629b95c Updated changelog 2021-04-24 18:01:41 +02:00
Alejandro Celaya
fa4664e583 Ensured edited short URLs are reflected in redux state when needed 2021-04-24 17:58:37 +02:00
Alejandro Celaya
2952ac8892 Merge pull request #406 from acelaya-forks/feature/reduce-margins
Feature/reduce margins
2021-03-29 21:26:49 +02:00
Alejandro Celaya
cf4fc4fa30 Updated changelog 2021-03-29 21:22:28 +02:00
Alejandro Celaya
2d61748aac Fixed styles in disabled days in date picker 2021-03-29 21:21:53 +02:00
Alejandro Celaya
7f61825768 Reduced and standardized overall vertical spacing 2021-03-29 21:08:48 +02:00
Alejandro Celaya
c3d6c83ec4 Merge pull request #405 from acelaya-forks/feature/orphan-visits-improvements
Feature/orphan visits improvements
2021-03-28 21:07:52 +02:00
Alejandro Celaya
c3e38fd580 Improved VisitsParser test 2021-03-28 21:03:46 +02:00
Alejandro Celaya
db778a73f7 Updated changelog 2021-03-28 20:57:19 +02:00
Alejandro Celaya
f0a04ced75 Added graph with orphan visits grouped by visited URL 2021-03-28 20:56:16 +02:00
Alejandro Celaya
d6bb718672 Added filtering by type to orphan visits 2021-03-28 17:45:47 +02:00
Alejandro Celaya
6d887ec4a8 Replaced custom reducers with ramda's countBy 2021-03-28 16:27:31 +02:00
Alejandro Celaya
859cd9e5e3 Improved VisitsTable test 2021-03-28 16:06:37 +02:00
Alejandro Celaya
eabd7d9ecb Added visited URL column on visits table for orphan visits 2021-03-28 15:57:22 +02:00
Alejandro Celaya
205e3ffb90 Merge pull request #404 from acelaya-forks/feature/edit-title
Feature/edit title
2021-03-27 19:01:02 +01:00
Alejandro Celaya
8c7a91c7b8 Memoized initial state for editing short URL, to ensure the form values are not reset while saving 2021-03-27 18:56:24 +01:00
Alejandro Celaya
56aab349db Updated ShortUrlForm to ensure it does not render empty cards 2021-03-27 18:39:55 +01:00
Alejandro Celaya
6628a4059e Updated changelog 2021-03-27 17:58:17 +01:00
Alejandro Celaya
10c9f7dabd Added header to EditShortUrl and created EditSHortUrl test 2021-03-27 17:56:46 +01:00
Alejandro Celaya
d703e5e182 Deleted reducers for short URL tags and short URL meta 2021-03-27 14:13:10 +01:00
Alejandro Celaya
3ad0c4d009 Deleted modals that were used to edit short URLs, since now there's a dedicated section 2021-03-27 10:49:23 +01:00
Alejandro Celaya
1403538660 Removed children from ShortUrlForm 2021-03-27 10:41:13 +01:00
Alejandro Celaya
ca670d810d Added error/loading handling to edit short URL 2021-03-27 10:27:46 +01:00
Alejandro Celaya
d5e20f445d Ensured title is not sent when its value is empty during short URL creation/edition 2021-03-27 10:19:35 +01:00
Alejandro Celaya
eea76d88c3 Ensured all data can be set when editing a short URL 2021-03-27 09:49:47 +01:00
Alejandro Celaya
a019bd30df Created view to edit short URLs 2021-03-20 16:32:12 +01:00
Alejandro Celaya
631b46393b Added title to short URL form 2021-03-20 11:18:00 +01:00
Alejandro Celaya
98aa85ca14 Created reusable component to have a short URL form 2021-03-19 19:11:27 +01:00
Alejandro Celaya
ea01d22369 Merge pull request #403 from acelaya-forks/feature/export-stats
Feature/export stats
2021-03-14 18:18:29 +01:00
Alejandro Celaya
ff1d2f63c8 Updated changelog 2021-03-14 18:14:10 +01:00
Alejandro Celaya
71468379bd Fixed headers when exporting visits to CSV 2021-03-14 18:12:10 +01:00
Alejandro Celaya
843f646264 Improved styling of the export visits button 2021-03-14 13:31:58 +01:00
Alejandro Celaya
508623f89f Improved styling of the export visits button 2021-03-14 13:30:50 +01:00
Alejandro Celaya
482489599e Created VisitsExporter test 2021-03-14 13:16:24 +01:00
Alejandro Celaya
03f63e3ee3 Added button to export visits as CSV 2021-03-14 12:53:01 +01:00
Alejandro Celaya
3f3523b80f Extracted helper function to generate a Csv file 2021-03-14 11:47:23 +01:00
Alejandro Celaya
1594717f33 Merge pull request #402 from acelaya-forks/feature/visits-default-value
Feature/visits default value
2021-03-06 17:38:08 +01:00
Alejandro Celaya
ed92b9c949 Updated changelog 2021-03-06 17:33:34 +01:00
Alejandro Celaya
e76b22b2ae Ensured consistent heights in settings cards 2021-03-06 17:30:21 +01:00
Alejandro Celaya
e380ddb40f Replaced test by it in tests 2021-03-06 17:25:09 +01:00
Alejandro Celaya
426d000a59 Added tests for new visits settings 2021-03-06 17:21:23 +01:00
Alejandro Celaya
fee62484b5 Created section to set default date interval for visits 2021-03-06 16:54:43 +01:00
Alejandro Celaya
d3f9650e82 Added new visits settings 2021-03-06 10:56:49 +01:00
Alejandro Celaya
ad46927750 Merge pull request #401 from acelaya-forks/feature/feature-improvements
Feature/feature improvements
2021-03-06 10:21:56 +01:00
Alejandro Celaya
bd79230007 Fixed version definition 2021-03-06 10:16:13 +01:00
Alejandro Celaya
5224e7b4ef Created new feature checkers 2021-03-06 09:58:29 +01:00
Alejandro Celaya
70ce099913 Added stricter types for SemVer versions 2021-03-06 09:38:48 +01:00
Alejandro Celaya
b4c2fb5b8f Merge pull request #400 from acelaya-forks/feature/improve-reducer
Feature/improve reducer
2021-03-05 16:31:18 +01:00
Alejandro Celaya
6fbf65c873 Updated changelog 2021-03-05 16:26:29 +01:00
Alejandro Celaya
13d3a95a06 Improved short URL detail redux action so that it avoids API calls when the URL is found in local state 2021-03-05 16:25:20 +01:00
Alejandro Celaya
56b3523c5b Moved short URL detail reducer to short-urls module 2021-03-05 16:04:02 +01:00
Alejandro Celaya
8a69adfbc9 Merge pull request #399 from acelaya-forks/feature/title-in-list
Feature/title in list
2021-03-05 15:48:59 +01:00
Alejandro Celaya
87a32b412f Added short URL title to visits header 2021-03-05 15:44:15 +01:00
Alejandro Celaya
df87ad5867 Updated changelog 2021-03-05 15:24:38 +01:00
Alejandro Celaya
f15bbcd027 Improved ShortUrlsRow test 2021-03-05 15:23:38 +01:00
Alejandro Celaya
3c9c0fe994 Added support for title field in short URL table 2021-03-05 14:20:49 +01:00
Alejandro Celaya
a665e96908 Updated to coding-standard v1.2.2 2021-03-05 11:14:58 +01:00
Alejandro Celaya
fddba80b08 Merge pull request #396 from acelaya-forks/feature/updated-deps
Feature/updated deps
2021-02-28 19:11:02 +01:00
Alejandro Celaya
caa3a09827 Fixed TS error in test 2021-02-28 19:00:11 +01:00
Alejandro Celaya
fa70520f38 Fixed props interface definition 2021-02-28 18:57:27 +01:00
Alejandro Celaya
b789f64a54 Updated changelog 2021-02-28 18:51:18 +01:00
Alejandro Celaya
ce0fc1094e Enabled @typescript-eslint/no-unsafe-return eslint rule again 2021-02-28 18:48:36 +01:00
Alejandro Celaya
ad0a889548 Enabled @typescript-eslint/no-base-to-string eslint rule again 2021-02-28 18:22:44 +01:00
Alejandro Celaya
1fe76500e8 Enabled @typescript-eslint/no-unsafe-call eslint rule again 2021-02-28 17:43:41 +01:00
Alejandro Celaya
86544f4b24 Enabled @typescript-eslint/unbound-method eslint rule again 2021-02-28 17:21:26 +01:00
Alejandro Celaya
c8f8416c06 No longer continue on error when linting fails during CI 2021-02-28 17:02:47 +01:00
Alejandro Celaya
3d2228441a Enabled @typescript-eslint/ban-ts-comment eslint rule 2021-02-28 13:11:27 +01:00
Alejandro Celaya
3f616d5482 Updated to @shlinkio/eslint-config-js-coding-standard@1.2.1 2021-02-28 13:01:11 +01:00
Alejandro Celaya
47fb26368b Updated dependencies and fixed coding styles 2021-02-28 12:56:56 +01:00
Alejandro Celaya
fb2194d2d1 Updated dependencies 2021-02-28 11:13:07 +01:00
Alejandro Celaya
8ec49b8cfc Merge pull request #394 from acelaya-forks/feature/orphan-visits-stats
Feature/orphan visits stats
2021-02-28 11:00:33 +01:00
Alejandro Celaya
4d77c3abf9 Updated changelog 2021-02-28 10:46:57 +01:00
Alejandro Celaya
d921c44d3b Created orphanVisitsReducer test 2021-02-28 10:45:14 +01:00
Alejandro Celaya
eb0ab92472 Created OrphanVisits test 2021-02-28 10:36:56 +01:00
Alejandro Celaya
9904ac757b Updated mercure integration so that the hook accepts a list of topics to subscribe 2021-02-28 10:12:30 +01:00
Alejandro Celaya
71ee886e24 Updated overview page cards to be links to other sections when suitable 2021-02-28 09:50:01 +01:00
Alejandro Celaya
25e53bf627 Created MenuLayout test 2021-02-28 09:28:46 +01:00
Alejandro Celaya
d7edd69e60 Created OrphanVisitsTitle test 2021-02-28 08:52:31 +01:00
Alejandro Celaya
115038f80f Created visits type helpers test 2021-02-27 20:13:18 +01:00
Alejandro Celaya
5479210366 Created section to display orphan visits stats 2021-02-27 20:03:51 +01:00
Alejandro Celaya
46d012b6ff Merge pull request #393 from acelaya-forks/feature/patch-tags
Feature/patch tags
2021-02-27 10:03:36 +01:00
Alejandro Celaya
80dcbf0668 Updated changelog 2021-02-27 09:51:31 +01:00
Alejandro Celaya
d0825089d0 Enhanced edit tags action so that it calls PATCH endpoint 2021-02-27 09:49:56 +01:00
Alejandro Celaya
f653739d50 Merge pull request #391 from acelaya-forks/feature/dark-theme
Feature/dark theme
2021-02-27 09:02:58 +01:00
Alejandro Celaya
2553b27d7d Rolled-back blurred modal 2021-02-27 08:52:10 +01:00
Alejandro Celaya
3cd30b61e4 More style fixes for dark theme 2021-02-27 08:34:44 +01:00
Alejandro Celaya
ae4921b865 Improved contrast in input border colors for dark theme 2021-02-26 23:10:19 +01:00
Alejandro Celaya
c89bcab770 Improved contrast in border colors for dark theme 2021-02-26 23:03:14 +01:00
Alejandro Celaya
f97ef8df83 Added proper blurred background for modals 2021-02-21 21:05:59 +01:00
Alejandro Celaya
e7466ced18 Added dark theme styles for date picker 2021-02-21 21:05:59 +01:00
Alejandro Celaya
0ee899f309 Updated changelog 2021-02-21 21:05:59 +01:00
Alejandro Celaya
36c97ad804 Updated styles in tags section to make it more dark-theme friendly 2021-02-21 21:05:30 +01:00
Alejandro Celaya
d6633f7555 More dark theme styles for visits page 2021-02-21 21:05:30 +01:00
Alejandro Celaya
61af43f9d9 Fixed visits table styles for dark theme 2021-02-21 21:05:30 +01:00
Alejandro Celaya
9523277311 Added icon to show which theme is selected 2021-02-21 21:05:30 +01:00
Alejandro Celaya
9703eba6ec Fixed styles for disabled inputs in dark theme 2021-02-21 21:05:30 +01:00
Alejandro Celaya
83791157ce Fixed inputs colors in dark theme when they are outside of cards 2021-02-21 21:05:30 +01:00
Alejandro Celaya
7f6c71e8d7 Created UserInterface test 2021-02-21 21:05:30 +01:00
Alejandro Celaya
9dbf790cc8 Added components and logic to dynamically change theme 2021-02-21 21:05:30 +01:00
Alejandro Celaya
f313a39b81 Added brand color and input styles to dark theme 2021-02-21 21:05:30 +01:00
Alejandro Celaya
53f16ac8b5 Added primary color alfa and tables color 2021-02-21 21:05:30 +01:00
Alejandro Celaya
13c681dc39 Added first bits of the dark theme styles 2021-02-21 21:05:30 +01:00
Alejandro Celaya
f35be007c1 Merge pull request #392 from acelaya-forks/feature/orphan-visits-card
Feature/orphan visits card
2021-02-21 21:04:37 +01:00
Alejandro Celaya
e2d26e8bdd Updated changelog 2021-02-21 20:57:57 +01:00
Alejandro Celaya
5a373fd7ae Added new card in overview to display orphan visits 2021-02-21 20:55:39 +01:00
Alejandro Celaya
3c53f7d0fc Merge pull request #389 from acelaya-forks/feature/validate-urls-setting
Feature/validate urls setting
2021-02-14 17:38:08 +01:00
Alejandro Celaya
57e3db1e1c Updated changelog 2021-02-14 17:34:20 +01:00
Alejandro Celaya
5afd3869dd Created ShortUrlCreation test 2021-02-14 17:33:01 +01:00
Alejandro Celaya
c3ebb0d10f Added test for setShortUrlCreationSettings action 2021-02-14 13:28:17 +01:00
Alejandro Celaya
4885088d59 Added option to customize initial state fo the 'Validate URL' option 2021-02-14 13:23:42 +01:00
Alejandro Celaya
872890e674 Merge pull request #388 from acelaya-forks/feature/qr-code-margin
Feature/qr code margin
2021-02-14 10:34:18 +01:00
Alejandro Celaya
8a2e39a935 Added subtle shadow in QR code image, so that it's easier to notice the margin 2021-02-14 10:21:10 +01:00
Alejandro Celaya
f8edcda665 Updated changelog 2021-02-14 10:17:34 +01:00
Alejandro Celaya
c95cb144a8 Added margin option to QR code component 2021-02-14 10:16:30 +01:00
Alejandro Celaya
f9da22c5a1 Added support for margin param in buildQrCodeUrl function 2021-02-14 09:50:26 +01:00
Alejandro Celaya
be085f50e0 Updated to bootstrap 4.6 2021-02-14 09:29:24 +01:00
Alejandro Celaya
1122f4e560 Merge pull request #381 from acelaya-forks/feature/qr-code-improvements
Feature/qr code improvements
2021-01-24 18:38:51 +01:00
Alejandro Celaya
ecefa22204 Replace nested ternary conditions with ramda's cond 2021-01-24 18:21:04 +01:00
Alejandro Celaya
e2ba63ff58 Updated changelog 2021-01-24 18:14:19 +01:00
Alejandro Celaya
277069a0af Fixed warnings in SortingDropdown 2021-01-24 18:12:16 +01:00
Alejandro Celaya
0c9434b555 Created CopyToClipboardIcon test 2021-01-24 18:06:11 +01:00
Alejandro Celaya
0fce6dd821 Fixed warnings in DropdownBtn test 2021-01-24 18:05:37 +01:00
Alejandro Celaya
4b8e5bf3fc Added qrCode helper test 2021-01-24 17:47:03 +01:00
Alejandro Celaya
3546a17575 Improved QR code modal, to allow selecting size, format and copy URL 2021-01-24 17:37:31 +01:00
Alejandro Celaya
556495ea7e Improved QR code component 2021-01-21 16:51:54 +01:00
Alejandro Celaya
e9cef8a029 Merge pull request #375 from acelaya-forks/feature/servers-import-error
Feature/servers import error
2020-12-30 21:04:59 +01:00
Alejandro Celaya
e577eb48d6 Changed env for github workflows from ubuntu-latest to ubuntu-20.04 2020-12-30 20:58:36 +01:00
Alejandro Celaya
d08a69954a Updated changelog 2020-12-30 20:53:14 +01:00
Alejandro Celaya
fe81bfccef Fixed importing servers in android due to wrong mime type 2020-12-30 20:52:05 +01:00
Alejandro Celaya
4869435aca Changed linting order 2020-12-30 20:10:37 +01:00
Alejandro Celaya
0822cebb10 Merge pull request #374 from acelaya-forks/feature/responsive-table
Feature/responsive table
2020-12-30 20:09:42 +01:00
Alejandro Celaya
01a18f2342 Updated changelog 2020-12-30 20:05:53 +01:00
Alejandro Celaya
a22274f382 Increased breakpoint in which short URLs table collapses 2020-12-30 20:05:04 +01:00
Alejandro Celaya
c0098ac7fd Merge pull request #373 from acelaya-forks/feature/ui-fixes
Fixed minor UI glitches in visits section
2020-12-30 19:52:24 +01:00
Alejandro Celaya
ba5a99dc2a Fixed minor UI glitches in visits section 2020-12-30 19:48:02 +01:00
Alejandro Celaya
1927ad2d3a Merge pull request #370 from acelaya-forks/feature/stryker-updates
Updated stryker
2020-12-25 19:13:57 +01:00
Alejandro Celaya
0356a0204d Updated stryker 2020-12-25 19:10:35 +01:00
Alejandro Celaya
3bf64bee1e Merge pull request #369 from acelaya-forks/feature/consistent-dropdowns
Feature/consistent dropdowns
2020-12-25 11:25:48 +01:00
Alejandro Celaya
da484374a1 Renamed Dropdnown.scss to DropdownBtn.scss for consistency with component 2020-12-25 11:21:39 +01:00
Alejandro Celaya
7b9447b717 Updated changelog 2020-12-25 11:17:57 +01:00
Alejandro Celaya
e583eb2759 Ensured sorting dropdown for short URLs is not enclosed inside card 2020-12-25 11:15:49 +01:00
Alejandro Celaya
93b4de60f6 Improved sorting dropdown to display order field and order dir 2020-12-25 11:06:10 +01:00
Alejandro Celaya
16f4f7eac8 Reused dropdown-btn styles in sorting dropdown 2020-12-25 10:54:49 +01:00
Alejandro Celaya
90d4fe72db Renamed Dropdown component to DropdownBtn 2020-12-25 10:43:36 +01:00
Alejandro Celaya
e1298cfa81 Created Dropdown test 2020-12-25 10:39:54 +01:00
Alejandro Celaya
6be3a1223f Created common Dropdown component for style consistency 2020-12-25 10:29:25 +01:00
Alejandro Celaya
81d24432a9 Updated app gif 2020-12-24 10:58:59 +01:00
367 changed files with 51182 additions and 10737 deletions

View File

@@ -5,3 +5,4 @@
./test
./shlink-web-client.gif
./dist
./docs

View File

@@ -14,16 +14,9 @@
"process": true,
"setImmediate": true
},
"ignorePatterns": ["src/service*.ts"],
"rules": {
"max-len": ["error", {
"code": 120,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreComments": true
}],
"no-mixed-operators": "off",
"react/display-name": "off",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/require-array-sort-compare": "off"
"complexity": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off"
}
}

View File

@@ -5,55 +5,12 @@ on:
push:
branches:
- main
- develop
jobs:
lint:
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run lint
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run test:ci
- name: Publish coverage
uses: codecov/codecov-action@v1
with:
file: ./coverage/clover.xml
mutation-tests:
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 0 # needed so that the main branch is also fetched
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
build-docker-image:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- run: docker build -t shlink-web-client:test .
ci:
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
with:
node-version: 16.13
with-mutation-tests: true
publish-coverage: true

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

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

View File

@@ -3,13 +3,13 @@ name: Build docker image
on:
push:
branches:
- main
- develop
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@@ -7,21 +7,20 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use node.js 14.15
- name: Use node.js
uses: actions/setup-node@v1
with:
node-version: 14.15
node-version: 16.13
- name: Generate release assets
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
- name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOW_TAG_PREFIX: "true"
ALLOW_EMPTY_CHANGELOG: "true"
with:
args: |

View File

@@ -4,6 +4,301 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.5.0] - 2022-01-01
### Added
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
### Changed
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [3.4.2] - 2021-12-07
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#530](https://github.com/shlinkio/shlink-web-client/issues/530) Fixed crash on domains page when default domain has an explicitly set port.
## [3.4.1] - 2021-11-20
### Added
* [#525](https://github.com/shlinkio/shlink-web-client/issues/525) Added docs section for Architectural Decision Records, including the one for servers "auto-connect".
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#520](https://github.com/shlinkio/shlink-web-client/issues/520) Fixed landing page scroll on mobile devices and improved its design.
* [#526](https://github.com/shlinkio/shlink-web-client/issues/526) Ensured exported servers do not include the `autoConnect` prop.
## [3.4.0] - 2021-11-11
### Added
* [#496](https://github.com/shlinkio/shlink-web-client/issues/496) Allowed to select "all visits" as the default interval for visits.
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
* [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens.
* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky.
* [#515](https://github.com/shlinkio/shlink-web-client/issues/515) Allowed to sort tags even when using the cards display mode.
* [#518](https://github.com/shlinkio/shlink-web-client/issues/518) Improved short URLs list filtering by moving selected tags, search text and dates to the query string, allowing to navigate back and forth or even bookmark filters.
### Changed
* Moved ci workflow to external repo and reused
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#252](https://github.com/shlinkio/shlink-web-client/issues/252) Fixed visits coming from mercure being added in real time, even when selected date interval does not match tha visit's date.
* [#48](https://github.com/shlinkio/shlink-web-client/issues/48) Fixed error when selected page gets out of range after filtering short URLs list by text, tags or dates. Now the page is reset to 1 in any of those cases.
## [3.3.2] - 2021-10-17
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#503](https://github.com/shlinkio/shlink-web-client/issues/503) Fixed short URLs title not being resettable after creation.
## [3.3.1] - 2021-09-27
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#497](https://github.com/shlinkio/shlink-web-client/issues/497) Fixed crash in domains section when one of the domains have more than one dot.
## [3.3.0] - 2021-09-25
### Added
* [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
* [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title.
* [#462](https://github.com/shlinkio/shlink-web-client/issues/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
* [#463](https://github.com/shlinkio/shlink-web-client/issues/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
* `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
* `includes`: Suggests tags that contain the input.
* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
* [#459](https://github.com/shlinkio/shlink-web-client/issues/459) Added new list mode to display tags.
The mode is optional, and you can toggle between the classic cards mode or the new list mode whenever you want.
You can also configure the default mode from settings.
### Changed
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand.
* [#409](https://github.com/shlinkio/shlink-web-client/issues/409) Increased required code coverage and added hard threshold check.
### Deprecated
* *Nothing*
### Removed
* [#491](https://github.com/shlinkio/shlink-web-client/issues/491) Dropped support for Shlink older than v2.4.0.
### Fixed
* *Nothing*
## [3.2.1] - 2021-09-12
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#478](https://github.com/shlinkio/shlink-web-client/issues/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
* [#480](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed servers import on Chromium-based browsers when using windows.
* [#482](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
## [3.2.0] - 2021-07-12
### Added
* [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars:
* `SHLINK_SERVER_URL`: The URL of the Shlink server to configure by default.
* `SHLINK_SERVER_API_KEY`: The API key of the Shlink server.
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
* [#440](https://github.com/shlinkio/shlink-web-client/issues/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
* [#431](https://github.com/shlinkio/shlink-web-client/issues/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
* [#430](https://github.com/shlinkio/shlink-web-client/issues/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design.
* [#449](https://github.com/shlinkio/shlink-web-client/issues/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
### Changed
* [#442](https://github.com/shlinkio/shlink-web-client/issues/442) Visits filtering now goes through the corresponding reducer.
* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns.
* [#360](https://github.com/shlinkio/shlink-web-client/issues/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#438](https://github.com/shlinkio/shlink-web-client/issues/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
## [3.1.2] - 2021-06-06
### Added
* *Nothing*
### Changed
* [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#371](https://github.com/shlinkio/shlink-web-client/issues/371) Recovered PWA functionality.
## [3.1.1] - 2021-05-08
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#413](https://github.com/shlinkio/shlink-web-client/issues/413) Fixed edit short URL form reflecting outdated info after navigating back from other section.
* [#412](https://github.com/shlinkio/shlink-web-client/issues/412) Ensured new visits coming from mercure hub are prepended and not appended, to keep proper sorting.
* [#417](https://github.com/shlinkio/shlink-web-client/issues/417) Fixed link spanning out of QR code modal.
* [#411](https://github.com/shlinkio/shlink-web-client/issues/411) Added missing feedback when editing a short URL to know if everything went right.
## [3.1.0] - 2021-03-29
### Added
* [#379](https://github.com/shlinkio/shlink-web-client/issues/379) and [#384](https://github.com/shlinkio/shlink-web-client/issues/384) Improved QR code modal, including controls to customize size, format and margin, as well as a button to copy the link to the clipboard.
* [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default.
* [#386](https://github.com/shlinkio/shlink-web-client/issues/386) Added new card in overview section to display amount of orphan visits when using Shlink 2.6.0 or higher.
* [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme.
* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) and [#395](https://github.com/shlinkio/shlink-web-client/issues/395) Added a section to see orphan visits stats, when consuming Shlink >=2.6.0.
* [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0.
* [#368](https://github.com/shlinkio/shlink-web-client/issues/368) Added new settings to define the default interval for visits pages.
* [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV.
* [#397](https://github.com/shlinkio/shlink-web-client/issues/397) New section to edit all data for short URLs, including title when using Shlink v2.6 or newer.
This new section replaces the old modals to edit short URL meta, short URL tags and the long URL. Everything is now together in the same section.
### Changed
* [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher.
* [#398](https://github.com/shlinkio/shlink-web-client/issues/398) Improved performance when loading short URL details by avoiding API calls if the short URL is already present in local state.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#335](https://github.com/shlinkio/shlink-web-client/issues/335) Fixed linting errors.
## [3.0.1] - 2020-12-30
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#364](https://github.com/shlinkio/shlink-web-client/issues/364) Fixed all dropdowns so that they are consistently styled.
* [#366](https://github.com/shlinkio/shlink-web-client/issues/366) Fixed text in visits menu jumping to next line in some tablet resolutions.
* [#367](https://github.com/shlinkio/shlink-web-client/issues/367) Removed conflicting overflow in visits table for mobile devices.
* [#365](https://github.com/shlinkio/shlink-web-client/issues/365) Fixed weird rendering of short URLs list in tablets.
* [#372](https://github.com/shlinkio/shlink-web-client/issues/372) Fixed importing servers in Android devices.
## [3.0.0] - 2020-12-22
### Added
* [#340](https://github.com/shlinkio/shlink-web-client/issues/340) Added new "overview" page, showing basic information of the active server.

View File

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

View File

@@ -1,7 +1,7 @@
# shlink-web-client
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink-web-client/Continuous%20integration/main?logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink-web-client/main?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink-web-client)
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink-web-client/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink-web-client/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink-web-client)
[![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?logo=docker&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/main/LICENSE)
@@ -68,6 +68,25 @@ Those servers can be exported and imported in other browsers, but if for some re
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/my-config/:/usr/share/nginx/html/conf.d/ shlinkio/shlink-web-client
If you want to pre-configure a single server, you can provide its config via env vars. When the container starts up, it will build the `servers.json` file dynamically based on them. *(since shlink-web-client 3.2.0)*.
* `SHLINK_SERVER_URL`: The fully qualified URL for the Shlink server.
* `SHLINK_SERVER_API_KEY`: The API key.
* `SHLINK_SERVER_NAME`: The name to be displayed. Defaults to **Shlink** if not provided.
```shell
docker run \
--name shlink-web-client \
-p 8000:80 \
-e SHLINK_SERVER_URL=https://doma.in \
-e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \
shlinkio/shlink-web-client
```
> **Be extremely careful when using this feature.**
>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
# How to handle setting auto-connect on servers
* Status: Accepted
* Date: 2021-10-31
## Context and problem statement
A new feature has been requested, to allow auto-connecting to servers. The request specifically mentioned doing it automatically when there's only one server configured, but it can be extended a bit to allow setting an "auto-connect" server, regardless the number of configured servers.
At all times, no more than one server can be set to "auto-connect" simultaneously. Setting a new one will effectively unset the previous one, if any.
## Considered option
* Auto-connect only of there's a single server configured.
* Allow to set the server as "auto-connect" during server creation, edition or import.
* Allow to set the server as "auto-connect" on a separated flow, where the full list of servers can be handled.
## Decision outcome
In order to make it more flexible, any server will be allowed to be set as "auto-connect", regardless the amount of configured servers.
Auto-connect will be handled from the new "Manage servers" section.
## Pros and Cons of the Options
### Only one server
* Good:
* Does not require extending models, and the logic to auto-connect is based on the amount of configured servers.
* Bad:
* It's not flexible enough.
* Makes the app behave differently depending on the amount of configured servers, making it confusing.
### Auto-connect configured on existing creation/edition/import
* Good:
* Does not require creating a new section to handle "auto-connect".
* Bad:
* Requires extending the server model with a new prop.
* It's much harder to ensure data consistency, as we need to ensure only one server is set to "auto-connect".
* On import, many servers might have been set to "auto-connect". The expected behavior there can be unclear.
### Auto-connect configured on new section
* Good:
* It's much easier to ensure data consistency.
* It's much more clear and predictable, as the UI shows which is the server configured as auto-connect.
* We have controls in a single place to set/unset auto connect on servers, allowing only the proper option based on current state for every server.
* Bad:
* Requires extending the server model with a new prop.
* Requires creating a new section to handle "auto-connect".

5
docs/adr/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Architectural Decision Records
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2021-10-31 How to handle setting auto-connect on servers](2021-10-31-how-to-handle-setting-auto-connect-on-servers.md)

View File

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

45845
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,100 +6,101 @@
"repository": "https://github.com/shlinkio/shlink-web-client",
"license": "MIT",
"scripts": {
"lint": "npm run lint:js && npm run lint:css",
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
"lint:js:fix": "npm run lint:js -- --fix",
"lint": "npm run lint:css && npm run lint:js",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:js": "eslint --ext .js,.ts,.tsx src test",
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix",
"start": "node scripts/start.js",
"serve:build": "serve ./build",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom --colors",
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
"test": "node scripts/test.js --env=jsdom --colors --verbose",
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.1",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.12",
"axios": "^0.21.0",
"bootstrap": "^4.5.3",
"@fortawesome/fontawesome-free": "^5.15.2",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-regular-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"axios": "^0.21.2",
"bootstrap": "^4.6.0",
"bottlejs": "^2.0.0",
"bowser": "^2.11.0",
"chart.js": "^2.9.4",
"chart.js": "^3.5.1",
"classnames": "^2.2.6",
"compare-versions": "^3.6.0",
"csvjson": "^5.1.0",
"event-source-polyfill": "^1.0.21",
"date-fns": "^2.22.1",
"event-source-polyfill": "^1.0.22",
"leaflet": "^1.7.1",
"moment": "^2.29.1",
"promise": "^8.1.0",
"qs": "^6.9.4",
"qs": "^6.9.6",
"ramda": "^0.27.1",
"react": "^17.0.1",
"react-autosuggest": "^10.0.3",
"react-chartjs-2": "^2.11.1",
"react-chartjs-2": "^3.0.4",
"react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "^3.3.0",
"react-datepicker": "^3.6.0",
"react-dom": "^17.0.1",
"react-external-link": "^1.2.0",
"react-leaflet": "^3.0.2",
"react-moment": "^1.0.0",
"react-leaflet": "^3.1.0",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-swipeable": "^6.0.0",
"react-tagsinput": "^3.19.0",
"reactstrap": "^8.7.1",
"react-swipeable": "^6.0.1",
"react-tag-autocomplete": "^6.1.0",
"reactstrap": "^8.9.0",
"redux": "^4.0.5",
"redux-localstorage-simple": "^2.3.1",
"redux-localstorage-simple": "^2.4.0",
"redux-thunk": "^2.3.0",
"uuid": "^8.3.1"
"uuid": "^8.3.2",
"workbox-core": "^6.1.5",
"workbox-expiration": "^6.1.5",
"workbox-precaching": "^6.1.5",
"workbox-routing": "^6.1.5",
"workbox-strategies": "^6.1.5"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
"@stryker-mutator/core": "^4.1.2",
"@stryker-mutator/jest-runner": "^4.1.2",
"@stryker-mutator/typescript-checker": "^4.1.2",
"@svgr/webpack": "^5.4.0",
"@types/chart.js": "^2.9.27",
"@babel/core": "^7.13.8",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^5.4.1",
"@stryker-mutator/jest-runner": "^5.4.1",
"@stryker-mutator/typescript-checker": "^5.4.1",
"@svgr/webpack": "^5.5.0",
"@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.8",
"@types/jest": "^26.0.15",
"@types/leaflet": "^1.5.19",
"@types/moment": "^2.13.0",
"@types/enzyme": "^3.10.10",
"@types/jest": "^27.0.2",
"@types/leaflet": "^1.5.23",
"@types/qs": "^6.9.5",
"@types/ramda": "^0.27.32",
"@types/react": "^16.9.56",
"@types/react-autosuggest": "^10.0.1",
"@types/ramda": "^0.27.38",
"@types/react": "^17.0.2",
"@types/react-color": "^3.0.4",
"@types/react-copy-to-clipboard": "^4.3.0",
"@types/react-datepicker": "^3.1.1",
"@types/react-dom": "^16.9.9",
"@types/react-copy-to-clipboard": "^5.0.0",
"@types/react-datepicker": "^3.1.5",
"@types/react-dom": "^17.0.1",
"@types/react-leaflet": "^2.5.2",
"@types/react-redux": "^7.1.11",
"@types/react-router-dom": "^5.1.6",
"@types/react-tagsinput": "^3.19.7",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"@types/react-tag-autocomplete": "^6.1.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/parser": "^4.7.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"adm-zip": "^0.4.16",
"autoprefixer": "^10.0.2",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^26.6.3",
"babel-jest": "^27.3.1",
"babel-loader": "^8.2.1",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0",
"babel-runtime": "^6.26.0",
"bfj": "^7.0.2",
"case-sensitive-paths-webpack-plugin": "^2.3.0",
"chalk": "^4.1.0",
"chalk": "^4.1.2",
"css-loader": "^5.0.1",
"dart-sass": "^1.25.0",
"dotenv": "^8.2.0",
@@ -112,9 +113,9 @@
"fs-extra": "^9.0.1",
"html-webpack-plugin": "^4.5.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"jest": "^27.3.1",
"jest-pnp-resolver": "^1.2.2",
"jest-resolve": "^26.6.2",
"jest-resolve": "^27.3.1",
"mini-css-extract-plugin": "^1.3.1",
"object-assign": "^4.1.1",
"optimize-css-assets-webpack-plugin": "^5.0.4",
@@ -130,7 +131,7 @@
"resolve": "^1.19.0",
"sass": "^1.29.0",
"sass-loader": "^10.1.0",
"serve": "^11.3.2",
"serve": "^12.0.0",
"stryker-cli": "^1.0.0",
"style-loader": "^2.0.0",
"stylelint": "^13.7.2",
@@ -140,14 +141,15 @@
"stylelint-scss": "^3.18.0",
"sw-precache-webpack-plugin": "^1.0.0",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^26.4.4",
"ts-jest": "^27.0.7",
"ts-mockery": "^1.2.0",
"typescript": "^4.0.5",
"typescript": "^4.4.4",
"url-loader": "^4.1.1",
"webpack": "^4.44.2",
"webpack-dev-server": "^3.11.0",
"webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.5.0"
"whatwg-fetch": "^3.5.0",
"workbox-webpack-plugin": "^6.1.5"
},
"babel": {
"presets": [

View File

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

View File

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

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

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

View File

@@ -1,11 +1,16 @@
declare module 'event-source-polyfill' {
export const EventSourcePolyfill: any;
declare class EventSourcePolyfill {
public onmessage?: ({ data }: { data: string }) => void;
public onerror?: ({ status }: { status: number }) => void;
public close: () => void;
public constructor(hubUrl: URL, options?: any);
}
}
declare module 'csvjson' {
export declare class CsvJson {
public toObject<T>(content: string): T[];
public toCSV<T>(data: T[], options: { headers: string }): string;
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -1,52 +0,0 @@
import { useEffect, FC } from 'react';
import { Route, Switch } from 'react-router-dom';
import NotFound from './common/NotFound';
import { ServersMap } from './servers/data';
import './App.scss';
interface AppProps {
fetchServers: Function;
servers: ServersMap;
}
const App = (
MainHeader: FC,
Home: FC,
MenuLayout: FC,
CreateServer: FC,
EditServer: FC,
Settings: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers }: AppProps) => {
// 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">
<div className="shlink-wrapper">
<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 className="shlink-footer">
<ShlinkVersionsContainer />
</div>
</div>
</div>
);
};
export default App;

View File

@@ -1,7 +1,5 @@
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
import { AxiosInstance, AxiosResponse, Method } from 'axios';
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
import { OptionalString } from '../../utils/utils';
import {
@@ -12,28 +10,35 @@ import {
ShlinkTagsResponse,
ShlinkVisits,
ShlinkVisitsParams,
ShlinkShortUrlMeta,
ShlinkDomain,
ShlinkShortUrlData,
ShlinkDomainsResponse,
ShlinkVisitsOverview,
ShlinkEditDomainRedirects,
ShlinkDomainRedirects,
ShlinkShortUrlsListParams,
ShlinkShortUrlsListNormalizedParams,
} from '../types';
import { stringifyQuery } from '../../utils/helpers/query';
import { orderToString } from '../../utils/helpers/ordering';
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : '';
const rejectNilProps = reject(isNil);
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
const { orderBy = {}, ...rest } = params;
return { ...rest, orderBy: orderToString(orderBy) };
};
export default class ShlinkApiClient {
private apiVersion: number;
public constructor(
private readonly axios: AxiosInstance,
private readonly baseUrl: string,
private readonly apiKey: string,
) {
this.apiVersion = 2;
}
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
.then(({ data }) => data.shortUrls);
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
@@ -51,6 +56,10 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);
@@ -63,6 +72,10 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {});
// eslint-disable-next-line valid-jsdoc
/**
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
*/
public readonly updateShortUrlTags = async (
shortCode: string,
domain: OptionalString,
@@ -71,13 +84,13 @@ export default class ShlinkApiClient {
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then(({ data }) => data.tags);
public readonly updateShortUrlMeta = async (
public readonly updateShortUrl = async (
shortCode: string,
domain: OptionalString,
meta: ShlinkShortUrlMeta,
): Promise<ShlinkShortUrlMeta> =>
this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
.then(() => meta);
data: ShlinkShortUrlData,
): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, data)
.then(({ data }) => data);
public readonly listTags = async (): Promise<ShlinkTags> =>
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
@@ -100,38 +113,21 @@ export default class ShlinkApiClient {
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
.then((resp) => resp.data);
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
try {
return await this.axios({
method,
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
headers: { 'X-Api-Key': this.apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});
} catch (e) {
const { response } = e;
public readonly editDomainRedirects = async (
domainRedirects: ShlinkEditDomainRedirects,
): Promise<ShlinkDomainRedirects> =>
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
// when performed from the browser (due to the preflight request not returning a 2xx status.
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
// if a request has been performed to a not supported API version.
const apiVersionIsNotSupported = !response;
// When the request is not invalid or we have already tried both API versions, throw the error and let the
// caller handle it
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
throw e;
}
this.apiVersion = this.apiVersion - 1;
return await this.performRequest(url, method, query, body);
}
};
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
this.axios({
method,
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
headers: { 'X-Api-Key': this.apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: stringifyQuery,
});
}

6
src/api/types/actions.ts Normal file
View File

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

View File

@@ -1,6 +1,6 @@
import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
export interface ShlinkShortUrlsResponse {
data: ShortUrl[];
@@ -25,12 +25,12 @@ interface ShlinkTagsStats {
export interface ShlinkTags {
tags: string[];
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
stats: ShlinkTagsStats[];
}
export interface ShlinkTagsResponse {
data: string[];
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
stats: ShlinkTagsStats[];
}
export interface ShlinkPaginator {
@@ -46,6 +46,7 @@ export interface ShlinkVisits {
export interface ShlinkVisitsOverview {
visitsCount: number;
orphanVisitsCount?: number; // Optional only for versions older than 2.6.0
}
export interface ShlinkVisitsParams {
@@ -54,19 +55,49 @@ export interface ShlinkVisitsParams {
itemsPerPage?: number;
startDate?: string;
endDate?: string;
excludeBots?: boolean;
}
export interface ShlinkShortUrlMeta extends ShortUrlMeta {
export interface ShlinkShortUrlData extends ShortUrlMeta {
longUrl?: string;
title?: string;
validateUrl?: boolean;
tags?: string[];
}
export interface ShlinkDomainRedirects {
baseUrlRedirect: string | null;
regular404Redirect: string | null;
invalidShortUrlRedirect: string | null;
}
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
domain: string;
}
export interface ShlinkDomain {
domain: string;
isDefault: boolean;
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
}
export interface ShlinkDomainsResponse {
data: ShlinkDomain[];
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
}
export interface ShlinkShortUrlsListParams {
page?: string;
itemsPerPage?: number;
tags?: string[];
searchTerm?: string;
startDate?: string;
endDate?: string;
orderBy?: ShortUrlsOrder;
}
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
orderBy?: string;
}
export interface ProblemDetailsError {
@@ -74,7 +105,6 @@ export interface ProblemDetailsError {
detail: string;
title: string;
status: number;
[extraProps: string]: any;
}

View File

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

68
src/app/App.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { useEffect, FC } from 'react';
import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
import classNames from 'classnames';
import NotFound from '../common/NotFound';
import { ServersMap } from '../servers/data';
import { Settings } from '../settings/reducers/settings';
import { changeThemeInMarkup } from '../utils/theme';
import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
interface AppProps extends RouteChildrenProps {
fetchServers: () => void;
servers: ServersMap;
settings: Settings;
resetAppUpdate: () => void;
appUpdated: boolean;
}
const App = (
MainHeader: FC,
Home: FC,
MenuLayout: FC,
CreateServer: FC,
EditServer: FC,
Settings: FC,
ManageServers: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
const isHome = location.pathname === '/';
useEffect(() => {
// On first load, try to fetch the remote servers if the list is empty
if (Object.keys(servers).length === 0) {
fetchServers();
}
changeThemeInMarkup(settings.ui?.theme ?? 'light');
}, []);
return (
<div className="container-fluid app-container">
<MainHeader />
<div className="app">
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/manage-servers" component={ManageServers} />
<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 className="shlink-footer">
<ShlinkVersionsContainer />
</div>
</div>
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
</div>
);
};
export default App;

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
.aside-menu {
width: $asideMenuWidth;
background-color: white;
background-color: var(--primary-color);
box-shadow: rgba(0, 0, 0, .05) 0 8px 15px;
position: fixed !important;
padding-top: 13px;
@@ -18,7 +18,6 @@
@media (min-width: $mdMin) {
padding: 30px 15px 15px;
border-right: 1px solid #eeeeee;
}
@media (max-width: $smMax) {
@@ -50,17 +49,13 @@
}
.aside-menu__item:hover {
background-color: $lightColor;
}
.aside-menu__item--selected {
color: #ffffff;
background-color: $mainColor;
background-color: var(--secondary-color);
}
.aside-menu__item--selected,
.aside-menu__item--selected:hover {
color: #ffffff;
background-color: $mainColor;
background-color: var(--brand-color);
}
.aside-menu__item--divider {

View File

@@ -4,6 +4,7 @@ import {
faTags as tagsIcon,
faPen as editIcon,
faHome as overviewIcon,
faGlobe as domainsIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
@@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom';
import classNames from 'classnames';
import { Location } from 'history';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { ServerWithId } from '../servers/data';
import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features';
import './AsideMenu.scss';
export interface AsideMenuProps {
selectedServer: ServerWithId;
selectedServer: SelectedServer;
className?: string;
showOnMobile?: boolean;
}
@@ -38,7 +40,9 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, showOnMobile = false }: AsideMenuProps,
) => {
const serverId = selectedServer ? selectedServer.id : '';
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
@@ -49,30 +53,38 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<AsideMenuItem to={buildPath('/overview')}>
<FontAwesomeIcon icon={overviewIcon} />
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<FontAwesomeIcon icon={listIcon} />
<FontAwesomeIcon fixedWidth icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/create-short-url')}>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/manage-tags')}>
<FontAwesomeIcon icon={tagsIcon} />
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</AsideMenuItem>
{addManageDomainsLink && (
<AsideMenuItem to={buildPath('/manage-domains')}>
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
<span className="aside-menu__item-text">Manage domains</span>
</AsideMenuItem>
)}
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon icon={editIcon} />
<FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer}
/>
{hasId && (
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer}
/>
)}
</nav>
</aside>
);

View File

@@ -4,6 +4,7 @@
.home {
position: relative;
padding-top: 15px;
width: 100%;
@media (min-width: $mdMin) {
padding-top: 0;
@@ -36,6 +37,6 @@
.home__servers-container {
@media (min-width: $mdMin) {
border-left: 1px solid rgba(0, 0, 0, .125);
border-left: 1px solid var(--border-color);
}
}

View File

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

View File

@@ -1,8 +1,8 @@
@import '../utils/base';
.main-header.main-header {
background-color: $mainColor !important;
color: white;
background-color: var(--brand-color) !important;
.navbar-brand {
color: inherit !important;

View File

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

View File

@@ -1,12 +1,11 @@
import { FC, useEffect } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { useSwipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useToggle } from '../utils/helpers/hooks';
import { versionMatch } from '../utils/helpers/version';
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data';
import NotFound from './NotFound';
import { AsideMenuProps } from './AsideMenu';
@@ -14,13 +13,16 @@ import './MenuLayout.scss';
const MenuLayout = (
TagsList: FC,
ShortUrls: FC,
ShortUrlsList: FC,
AsideMenu: FC<AsideMenuProps>,
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
OrphanVisits: FC,
ServerError: FC,
Overview: FC,
EditShortUrl: FC,
ManageDomains: FC,
) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
@@ -30,26 +32,10 @@ const MenuLayout = (
return <ServerError />;
}
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: () => void) => (e: any) => {
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
({ classList }) => classList?.contains('visits-table'),
);
if (swippedOnVisitsTable || document.querySelector('.modal')) {
return;
}
callback();
};
const swipeableProps = useSwipeable({
delta: 40,
onSwipedLeft: swipeMenuIfNoModalExists(hideSidebar),
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
});
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
return (
<>
@@ -63,11 +49,14 @@ const MenuLayout = (
<Switch>
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
<Route exact path="/server/:serverId/overview" component={Overview} />
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/>

View File

@@ -1,6 +1,4 @@
import { FC } from 'react';
import './NoMenuLayout.scss';
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
export default NoMenuLayout;
export const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;

View File

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

View File

@@ -1,48 +0,0 @@
.react-tagsinput {
background-color: #ffffff;
border: 1px solid #cccccc;
border-radius: .25rem;
overflow: hidden;
min-height: 2.6rem;
padding: .5rem 0 0 1rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.react-tagsinput--focused {
border-color: #80bdff;
box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
}
.react-tagsinput-tag {
font-size: 1rem;
background-color: #f1f1f1;
border-radius: 4px;
display: inline-block;
font-weight: 400;
margin: 0 5px 6px 0;
padding: 6px 8px;
line-height: 1;
color: #ffffff;
}
.react-tagsinput-remove {
cursor: pointer;
font-weight: 700;
margin-left: 8px;
}
.react-tagsinput-tag span:before {
content: '\2715';
color: #ffffff;
}
.react-tagsinput-input {
background: transparent;
border: 0;
outline: none;
padding: 1px 0;
width: 100%;
margin-bottom: 6px;
font-size: 1.25rem;
color: #495057;
}

View File

@@ -0,0 +1,13 @@
import { AxiosInstance } from 'axios';
import { saveUrl } from '../../utils/helpers/files';
export class ImageDownloader {
public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
public async saveImage(imgUrl: string, filename: string): Promise<void> {
const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
const url = URL.createObjectURL(data);
saveUrl(this.window, url, filename);
}
}

View File

@@ -9,12 +9,17 @@ import ErrorHandler from '../ErrorHandler';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ImageDownloader } from './ImageDownloader';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Services
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
// Components
bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
@@ -23,21 +28,25 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', withRouter);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory(
'MenuLayout',
MenuLayout,
'TagsList',
'ShortUrls',
'ShortUrlsList',
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'OrphanVisits',
'ServerError',
'Overview',
'EditShortUrl',
'ManageDomains',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');

View File

@@ -2,7 +2,6 @@ import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import App from '../App';
import provideApiServices from '../api/services/provideServices';
import provideCommonServices from '../common/services/provideServices';
import provideShortUrlsServices from '../short-urls/services/provideServices';
@@ -13,14 +12,17 @@ import provideUtilsServices from '../utils/services/provideServices';
import provideMercureServices from '../mercure/services/provideServices';
import provideSettingsServices from '../settings/services/provideServices';
import provideDomainsServices from '../domains/services/provideServices';
import provideAppServices from '../app/services/provideServices';
import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
const bottle = new Bottle();
const { container } = bottle;
const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => container[serviceName](...args);
export const { container } = bottle;
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
(...args: any[]) => (container[serviceName] as T)(...args) as K;
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called
@@ -32,22 +34,10 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
actionServiceNames.reduce(mapActionService, {}),
);
bottle.serviceFactory(
'App',
App,
'MainHeader',
'Home',
'MenuLayout',
'CreateServer',
'EditServer',
'Settings',
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
provideAppServices(bottle, connect, withRouter);
provideCommonServices(bottle, connect, withRouter);
provideApiServices(bottle);
provideShortUrlsServices(bottle, connect);
provideShortUrlsServices(bottle, connect, withRouter);
provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
@@ -55,5 +45,3 @@ provideUtilsServices(bottle);
provideMercureServices(bottle);
provideSettingsServices(bottle, connect);
provideDomainsServices(bottle, connect);
export default container;

View File

@@ -2,6 +2,8 @@ import ReduxThunk from 'redux-thunk';
import { applyMiddleware, compose, createStore } from 'redux';
import { save, load, RLSOptions } from 'redux-localstorage-simple';
import reducers from '../reducers';
import { migrateDeprecatedSettings } from '../settings/helpers';
import { ShlinkState } from './types';
const isProduction = process.env.NODE_ENV !== 'production';
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
@@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = {
namespaceSeparator: '.',
debounce: 300,
};
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
export const store = createStore(reducers, preloadedState, composeEnhancers(
applyMiddleware(save(localStorageConfig), ReduxThunk),
));
export default store;

View File

@@ -1,34 +1,30 @@
import { MercureInfo } from '../mercure/reducers/mercureInfo';
import { SelectedServer, ServersMap } from '../servers/data';
import { Settings } from '../settings/reducers/settings';
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlTags } from '../short-urls/reducers/shortUrlTags';
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
import { TagDeletion } from '../tags/reducers/tagDelete';
import { TagEdition } from '../tags/reducers/tagEdit';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { VisitsInfo } from '../visits/types';
export interface ShlinkState {
servers: ServersMap;
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsList;
shortUrlsListParams: ShortUrlsListParams;
shortUrlCreationResult: ShortUrlCreation;
shortUrlDeletion: ShortUrlDeletion;
shortUrlTags: ShortUrlTags;
shortUrlMeta: ShortUrlMetaEdition;
shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits;
orphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail;
tagsList: TagsList;
tagDelete: TagDeletion;
@@ -37,6 +33,7 @@ export interface ShlinkState {
settings: Settings;
domainsList: DomainsList;
visitsOverview: VisitsOverview;
appUpdated: boolean;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

88
src/domains/DomainRow.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { FC, useEffect } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBan as forbiddenIcon,
faDotCircle as defaultDomainIcon,
faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomainRedirects } from '../api/types';
import { useToggle } from '../utils/helpers/hooks';
import { OptionalString } from '../utils/utils';
import { SelectedServer } from '../servers/data';
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
import { Domain } from './data';
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
interface DomainRowProps {
domain: Domain;
defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
}
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
<span className="text-muted">
{!fallback && <small>No redirect</small>}
{fallback && <>{fallback} <small>(as fallback)</small></>}
</span>
);
const DefaultDomain: FC = () => (
<>
<FontAwesomeIcon fixedWidth icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
</>
);
export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
) => {
const [ isOpen, toggle ] = useToggle();
const { domain: authority, isDefault, redirects, status } = domain;
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
useEffect(() => {
checkDomainHealth(domain.domain);
}, []);
return (
<tr className="responsive-table__row">
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
<td className="responsive-table__cell" data-th="Base path redirect">
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
</td>
<td className="responsive-table__cell" data-th="Regular 404 redirect">
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
</td>
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
</td>
<td className="responsive-table__cell text-lg-center" data-th="Status">
<DomainStatusIcon status={status} />
</td>
<td className="responsive-table__cell text-right">
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
</Button>
</span>
{!canEditDomain && (
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
Redirects for default domain cannot be edited here.
<br />
Use config options or env vars directly on the server.
</UncontrolledTooltip>
)}
</td>
<EditDomainRedirectsModal
domain={domain}
isOpen={isOpen}
toggle={toggle}
editDomainRedirects={editDomainRedirects}
/>
</tr>
);
};

View File

@@ -1,31 +1,19 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover,
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active {
text-align: left;
color: #6c757d;
border-color: #ced4da;
background-color: white;
color: $textPlaceholder !important;
}
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
color: #495057;
color: var(--input-text-color) !important;
}
.domains-dropdown__back-btn.domains-dropdown__back-btn,
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
border-color: #ced4da;
}
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn::after {
right: .75rem;
@include vertical-align();
}
.domains-dropdown__menu {
width: 100%;
border-color: var(--border-color);
}

View File

@@ -1,20 +1,10 @@
import { useEffect } from 'react';
import {
Button,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
Input,
InputGroup,
InputGroupAddon,
UncontrolledTooltip,
} from 'reactstrap';
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
import { InputProps } from 'reactstrap/lib/Input';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { isEmpty, pipe } from 'ramda';
import classNames from 'classnames';
import { DropdownBtn } from '../utils/DropdownBtn';
import { useToggle } from '../utils/helpers/hooks';
import { DomainsList } from './reducers/domainsList';
import './DomainSelector.scss';
@@ -31,7 +21,6 @@ interface DomainSelectorConnectProps extends DomainSelectorProps {
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
const [ inputDisplayed,, showInput, hideInput ] = useToggle();
const [ isDropdownOpen, toggleDropdown ] = useToggle();
const { domains } = domainsList;
const valueIsEmpty = isEmpty(value);
const unselectDomain = () => onChange('');
@@ -63,33 +52,24 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
</InputGroupAddon>
</InputGroup>
) : (
<Dropdown isOpen={isDropdownOpen} toggle={toggleDropdown}>
<DropdownToggle
caret
className={classNames(
'domains-dropdown__toggle-btn btn-block',
{ 'domains-dropdown__toggle-btn--active': !valueIsEmpty },
)}
>
{valueIsEmpty && <>Domain</>}
{!valueIsEmpty && <>Domain: {value}</>}
</DropdownToggle>
<DropdownMenu className="domains-dropdown__menu">
{domains.map(({ domain, isDefault }) => (
<DropdownItem
key={domain}
active={value === domain || isDefault && valueIsEmpty}
onClick={() => onChange(domain)}
>
{domain}
{isDefault && <span className="float-right text-muted">default</span>}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
<i>New domain</i>
<DropdownBtn
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : 'domains-dropdown__toggle-btn'}
>
{domains.map(({ domain, isDefault }) => (
<DropdownItem
key={domain}
active={value === domain || isDefault && valueIsEmpty}
onClick={() => onChange(domain)}
>
{domain}
{isDefault && <span className="float-right text-muted">default</span>}
</DropdownItem>
</DropdownMenu>
</Dropdown>
))}
<DropdownItem divider />
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
<i>New domain</i>
</DropdownItem>
</DropdownBtn>
);
};

View File

@@ -0,0 +1,76 @@
import { FC, useEffect } from 'react';
import Message from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField';
import { ShlinkDomainRedirects } from '../api/types';
import { SelectedServer } from '../servers/data';
import { DomainsList } from './reducers/domainsList';
import { DomainRow } from './DomainRow';
interface ManageDomainsProps {
listDomains: Function;
filterDomains: (searchTerm: string) => void;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
domainsList: DomainsList;
selectedServer: SelectedServer;
}
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
) => {
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
useEffect(() => {
listDomains();
}, []);
if (loading) {
return <Message loading />;
}
const renderContent = () => {
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
</Result>
);
}
return (
<SimpleCard>
<table className="table table-hover mb-0">
<thead className="responsive-table__header">
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
</thead>
<tbody>
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
{domains.map((domain) => (
<DomainRow
key={domain.domain}
domain={domain}
editDomainRedirects={editDomainRedirects}
checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer}
/>
))}
</tbody>
</table>
</SimpleCard>
);
};
return (
<>
<SearchField className="mb-3" onChange={filterDomains} />
{renderContent()}
</>
);
};

View File

@@ -0,0 +1,7 @@
import { ShlinkDomain } from '../../api/types';
export type DomainStatus = 'validating' | 'valid' | 'invalid';
export interface Domain extends ShlinkDomain {
status: DomainStatus;
}

View File

@@ -0,0 +1,62 @@
import { FC, useEffect, useRef, useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faTimes as invalidIcon,
faCheck as checkIcon,
faCircleNotch as loadingStatusIcon,
} from '@fortawesome/free-solid-svg-icons';
import { MediaMatcher } from '../../utils/types';
import { DomainStatus } from '../data';
interface DomainStatusIconProps {
status: DomainStatus;
matchMedia?: MediaMatcher;
}
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
const ref = useRef<HTMLSpanElement>();
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile());
useEffect(() => {
const listener = () => setIsMobile(matchesMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
if (status === 'validating') {
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
}
return (
<>
<span
ref={(el: HTMLSpanElement) => {
ref.current = el;
}}
>
{status === 'valid'
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
</span>
<UncontrolledTooltip
target={(() => ref.current) as any}
placement={isMobile ? 'top-start' : 'left'}
autohide={status === 'valid'}
>
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
<span>
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
<br />
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
find out what is missing.
</span>
)}
</UncontrolledTooltip>
</>
);
};

View File

@@ -0,0 +1,72 @@
import { FC, useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import { InfoTooltip } from '../../utils/InfoTooltip';
interface EditDomainRedirectsModalProps {
domain: ShlinkDomain;
isOpen: boolean;
toggle: () => void;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
}
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
<FormGroupContainer
{...rest}
required={false}
type="url"
placeholder="No redirect"
className={isLast ? 'mb-0' : ''}
/>
);
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
{ isOpen, toggle, domain, editDomainRedirects },
) => {
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? '');
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? '');
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(
domain.redirects?.invalidShortUrlRedirect ?? '',
);
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
}).then(toggle));
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<form onSubmit={handleSubmit}>
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
<ModalBody>
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
<InfoTooltip className="mr-2" placement="bottom">
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
</InfoTooltip>
Base URL
</FormGroup>
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
<InfoTooltip className="mr-2" placement="bottom">
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
will be redirected to this URL.
</InfoTooltip>
Regular 404
</FormGroup>
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
<InfoTooltip className="mr-2" placement="bottom">
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
redirected to this URL.
</InfoTooltip>
Invalid short URL
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
<Button color="primary">Save</Button>
</ModalFooter>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,33 @@
import { Action, Dispatch } from 'redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkDomainRedirects } from '../../api/types';
import { GetState } from '../../container/types';
import { ApiErrorAction } from '../../api/types/actions';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
/* eslint-enable padding-line-between-statements */
export interface EditDomainRedirectsAction extends Action<string> {
domain: string;
redirects: ShlinkDomainRedirects;
}
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
domain: string,
domainRedirects: Partial<ShlinkDomainRedirects>,
) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
const { editDomainRedirects } = buildShlinkApiClient(getState);
try {
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
} catch (e: any) {
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
}
};

View File

@@ -1,35 +1,84 @@
import { Action, Dispatch } from 'redux';
import { ShlinkDomain } from '../../api/types';
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
import { Domain, DomainStatus } from '../data';
import { hasServerData } from '../../servers/data';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
/* eslint-disable padding-line-between-statements */
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
/* eslint-enable padding-line-between-statements */
export interface DomainsList {
domains: ShlinkDomain[];
domains: Domain[];
filteredDomains: Domain[];
defaultRedirects?: ShlinkDomainRedirects;
loading: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface ListDomainsAction extends Action<string> {
domains: ShlinkDomain[];
domains: Domain[];
defaultRedirects?: ShlinkDomainRedirects;
}
interface FilterDomainsAction extends Action<string> {
searchTerm: string;
}
interface ValidateDomain extends Action<string> {
domain: string;
status: DomainStatus;
}
const initialState: DomainsList = {
domains: [],
filteredDomains: [],
loading: false,
error: false,
};
export default buildReducer<DomainsList, ListDomainsAction>({
export type DomainsCombinedAction = ListDomainsAction
& ApiErrorAction
& FilterDomainsAction
& EditDomainRedirectsAction
& ValidateDomain;
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects };
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status };
export default buildReducer<DomainsList, DomainsCombinedAction>({
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
...state,
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
}),
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
...state,
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
}),
[VALIDATE_DOMAIN]: (state, { domain, status }) => ({
...state,
domains: state.domains.map(replaceStatusOnDomain(domain, status)),
filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)),
}),
}, initialState);
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
@@ -40,10 +89,42 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
const { listDomains } = buildShlinkApiClient(getState);
try {
const domains = await listDomains();
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
defaultRedirects,
}));
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
} catch (e) {
dispatch({ type: LIST_DOMAINS_ERROR });
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects });
} catch (e: any) {
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
}
};
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
dispatch: Dispatch,
getState: GetState,
) => {
const { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
return;
}
try {
const { url, ...rest } = selectedServer;
const { health } = buildShlinkApiClient({
...rest,
url: replaceAuthorityFromUri(url, domain),
});
const { status } = await health();
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
} catch (e) {
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
}
};

View File

@@ -1,15 +1,26 @@
import Bottle from 'bottlejs';
import { ConnectDecorator } from '../../container/types';
import { listDomains } from '../reducers/domainsList';
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect(
[ 'domainsList', 'selectedServer' ],
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
));
// Actions
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
bottle.serviceFactory('filterDomains', () => filterDomains);
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
};
export default provideServices;

View File

@@ -1,32 +1,90 @@
/* stylelint-disable no-descending-specificity */
@import './utils/base';
@import 'node_modules/bootstrap/scss/bootstrap.scss';
@import './common/react-tagsinput.scss';
@import './common/react-tag-autocomplete.scss';
@import './theme/theme';
@import './utils/table/ResponsiveTable';
@import './utils/StickyCardPaginator';
* {
outline: none !important;
}
html,
body,
#root {
height: 100%;
background: $lightColor;
}
* {
outline: none !important;
background: var(--secondary-color);
color: var(--text-color);
}
.bg-main {
background-color: $mainColor !important;
}
.card {
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
}
.card-header {
background-color: white;
.card-body,
.card-header,
.list-group-item {
background-color: transparent;
}
.card-footer {
background-color: rgba(255, 255, 255, .5);
background-color: var(--primary-color-alfa);
}
.card {
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
background-color: var(--primary-color);
border-color: var(--border-color);
}
.list-group {
background-color: var(--primary-color);
}
.modal-content,
.page-link,
.page-item.disabled .page-link,
.dropdown-menu {
background-color: var(--primary-color);
}
.modal-header,
.modal-footer,
.card-header,
.card-footer,
.table thead th,
.table th,
.table td,
.page-link,
.page-link:hover,
.page-item.disabled .page-link,
.dropdown-divider,
.dropdown-menu,
.list-group-item,
.modal-content,
hr {
border-color: var(--border-color);
}
.table-bordered,
.table-bordered thead th,
.table-bordered thead td {
border-color: var(--table-border-color);
}
.page-link:hover {
background-color: var(--secondary-color);
}
.page-item.active .page-link {
background-color: var(--brand-color);
border-color: var(--brand-color);
}
.pagination .page-link {
cursor: pointer;
}
.container-xl {
@@ -40,32 +98,71 @@ body,
}
}
.dropdown-item,
.dropdown-item-text {
color: var(--text-color);
}
.dropdown-item:not(:disabled) {
cursor: pointer;
}
.dropdown-item:focus:not(:disabled),
.dropdown-item:hover:not(:disabled),
.dropdown-item.active:not(:disabled),
.dropdown-item:active:not(:disabled) {
background-color: $lightGrey !important;
color: inherit !important;
background-color: var(--active-color) !important;
color: var(--text-color) !important;
}
.dropdown-item--danger.dropdown-item--danger {
color: $dangerColor;
&:hover,
&:active,
&.active {
color: $dangerColor !important;
}
}
.badge-main {
color: #ffffff;
background-color: $mainColor;
background-color: var(--brand-color);
}
.close,
.close:hover,
.table,
.table-hover tbody tr:hover {
color: var(--text-color);
}
.table-hover tbody tr:hover {
background-color: $lightColor;
background-color: var(--secondary-color);
}
.react-datepicker__input-container,
.react-datepicker-wrapper {
display: block !important;
.form-control,
.form-control:focus {
background-color: var(--primary-color);
border-color: var(--input-border-color);
color: var(--input-text-color);
}
.react-datepicker-popper {
z-index: 2;
.form-control.disabled,
.form-control:disabled {
background-color: var(--input-disabled-color);
cursor: not-allowed;
}
.card .form-control:not(:disabled),
.card .form-control:not(:disabled):hover {
background-color: var(--input-color);
}
.table-active,
.table-active > th,
.table-active > td {
background-color: var(--table-highlight-color) !important;
}
.navbar-brand {
@@ -74,10 +171,6 @@ body,
}
}
.pagination .page-link {
cursor: pointer;
}
.indivisible {
white-space: nowrap;
}
@@ -92,14 +185,6 @@ body,
white-space: nowrap;
}
.react-datepicker__day--keyboard-selected {
background-color: $mainColor;
&:hover {
background-color: darken($mainColor, 12%);
}
}
.progress-bar {
background-color: $mainColor;
}

View File

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

View File

@@ -0,0 +1,7 @@
export class Topics {
public static readonly visits = 'https://shlink.io/new-visit';
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
}

View File

@@ -6,13 +6,13 @@ import { bindToMercureTopic } from './index';
export interface MercureBoundProps {
createNewVisits: (createdVisits: CreateVisit[]) => void;
loadMercureInfo: Function;
loadMercureInfo: () => void;
mercureInfo: MercureInfo;
}
export function boundToMercureHub<T = {}>(
WrappedComponent: FC<MercureBoundProps & T>,
getTopicForProps: (props: T) => string,
getTopicsForProps: (props: T) => string[],
) {
const pendingUpdates = new Set<CreateVisit>();
@@ -22,7 +22,7 @@ export function boundToMercureHub<T = {}>(
useEffect(() => {
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo);
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicsForProps(props), onMessage, loadMercureInfo);
if (!interval) {
return closeEventSource;

View File

@@ -1,24 +1,31 @@
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
import { MercureInfo } from '../reducers/mercureInfo';
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topics: string[], onMessage: (message: T) => void, onTokenExpired: () => void) => { // eslint-disable-line max-len
const { mercureHubUrl, token, loading, error } = mercureInfo;
if (loading || error || !mercureHubUrl) {
return undefined;
}
const hubUrl = new URL(mercureHubUrl);
const onEventSourceMessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
const onEventSourceError = ({ status }: { status: number }) => status === 401 && onTokenExpired();
hubUrl.searchParams.append('topic', topic);
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
const subscriptions = topics.map((topic) => {
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', topic);
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
es.onmessage = onEventSourceMessage;
es.onerror = onEventSourceError;
return es;
});
es.onmessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired();
return () => es.close();
return () => subscriptions.forEach((es) => es.close());
};

View File

@@ -2,15 +2,13 @@ import { combineReducers } from 'redux';
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';
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 orphanVisitsReducer from '../visits/reducers/orphanVisits';
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit';
@@ -18,20 +16,19 @@ import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import appUpdatesReducer from '../app/reducers/appUpdates';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
servers: serversReducer,
selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer,
shortUrlMeta: shortUrlMetaReducer,
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
orphanVisits: orphanVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
@@ -40,4 +37,5 @@ export default combineReducers<ShlinkState>({
settings: settingsReducer,
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer,
});

View File

@@ -1,16 +0,0 @@
@import '../utils/base';
.create-server__label {
font-weight: 700;
cursor: pointer;
@media (min-width: $mdMin) {
text-align: right;
}
}
.create-server__csv-select {
position: absolute;
left: -9999px;
top: -9999px;
}

View File

@@ -1,52 +1,76 @@
import { FC } from 'react';
import { FC, useEffect, useState } from 'react';
import { v4 as uuid } from 'uuid';
import { RouterProps } from 'react-router';
import { Button } from 'reactstrap';
import { Result } from '../utils/Result';
import NoMenuLayout from '../common/NoMenuLayout';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServerWithId } from './data';
import './CreateServer.scss';
import { ServerData, ServersMap, ServerWithId } from './data';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps extends RouterProps {
createServer: (server: ServerWithId) => void;
servers: ServersMap;
}
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<Result type={type}>
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result>
<div className="mt-3">
<Result type={type}>
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result>
</div>
);
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
{ createServer, history: { push } }: CreateServerProps,
{ servers, createServer, history: { push, goBack } }: CreateServerProps,
) => {
const hasServers = !!Object.keys(servers).length;
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const handleSubmit = (serverData: ServerData) => {
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
const [ serverData, setServerData ] = useState<ServerData | undefined>();
const save = () => {
if (!serverData) {
return;
}
const id = uuid();
createServer({ ...serverData, id });
push(`/server/${id}`);
};
useEffect(() => {
const serverExists = Object.values(servers).some(
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
);
serverExists ? toggleConfirmModal() : save();
}, [ serverData ]);
return (
<NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
<button className="btn btn-outline-primary">Create server</button>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
{!hasServers &&
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ml-2">Create server</Button>
</ServerForm>
{(serversImported || errorImporting) && (
<div className="mt-4">
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
</div>
)}
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
<DuplicatedServersModal
isOpen={isConfirmModalOpen}
duplicatedServers={serverData ? [ serverData ] : []}
onDiscard={goBack}
onSave={save}
/>
</NoMenuLayout>
);
};

View File

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

View File

@@ -1,3 +1,4 @@
import { FC } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { RouterProps } from 'react-router';
import { ServerWithId } from './data';
@@ -6,17 +7,20 @@ export interface DeleteServerModalProps {
server: ServerWithId;
toggle: () => void;
isOpen: boolean;
redirectHome?: boolean;
}
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
deleteServer: (server: ServerWithId) => void;
}
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
) => {
const closeModal = () => {
deleteServer(server);
toggle();
history.push('/');
redirectHome && history.push('/');
};
return (

View File

@@ -1,6 +1,6 @@
import { FC } from 'react';
import { Button } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data';
@@ -10,7 +10,7 @@ interface EditServerProps {
}
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
{ editServer, selectedServer, history: { push, goBack } },
{ editServer, selectedServer, history: { goBack } },
) => {
if (!isServerWithId(selectedServer)) {
return null;
@@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}`);
goBack();
};
return (

View File

@@ -0,0 +1,86 @@
import { FC, useEffect, useState } from 'react';
import { Button, Row } from 'reactstrap';
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField';
import { Result } from '../utils/Result';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServersMap } from './data';
import { ManageServersRowProps } from './ManageServersRow';
import ServersExporter from './services/ServersExporter';
interface ManageServersProps {
servers: ServersMap;
}
const SHOW_IMPORT_MSG_TIME = 4000;
export const ManageServers = (
serversExporter: ServersExporter,
ImportServersBtn: FC<ImportServersBtnProps>,
useStateFlagTimeout: StateFlagTimeout,
ManageServersRow: FC<ManageServersRowProps>,
): FC<ManageServersProps> => ({ servers }) => {
const allServers = Object.values(servers);
const [ serversList, setServersList ] = useState(allServers);
const filterServers = (searchTerm: string) => setServersList(
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
);
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
useEffect(() => {
setServersList(Object.values(servers));
}, [ servers ]);
return (
<NoMenuLayout>
<SearchField className="mb-3" onChange={filterServers} />
<Row className="mb-3">
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{allServers.length > 0 && (
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button>
)}
</div>
<div className="col-md-6 text-md-right d-flex d-md-block">
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button>
</div>
</Row>
<SimpleCard>
<table className="table table-hover mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th style={{ width: '50px' }} />}
<th>Name</th>
<th>Base URL</th>
<th />
</tr>
</thead>
<tbody>
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
{serversList.map((server) =>
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />)
}
</tbody>
</table>
</SimpleCard>
{errorImporting && (
<div className="mt-3">
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
</div>
)}
</NoMenuLayout>
);
};

View File

@@ -0,0 +1,38 @@
import { FC } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
import { ServerWithId } from './data';
import { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
export interface ManageServersRowProps {
server: ServerWithId;
hasAutoConnect: boolean;
}
export const ManageServersRow = (
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>,
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => (
<tr className="responsive-table__row">
{hasAutoConnect && (
<td className="responsive-table__cell" data-th="Auto-connect">
{server.autoConnect && (
<>
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon" placement="right">
Auto-connect to this server
</UncontrolledTooltip>
</>
)}
</td>
)}
<th className="responsive-table__cell" data-th="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link>
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-right">
<ManageServersRowDropdown server={server} />
</td>
</tr>
);

View File

@@ -0,0 +1,53 @@
import { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBan as toggleOffIcon,
faEdit as editIcon,
faMinusCircle as deleteIcon,
faPlug as connectIcon,
} from '@fortawesome/free-solid-svg-icons';
import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import { useToggle } from '../utils/helpers/hooks';
import { DeleteServerModalProps } from './DeleteServerModal';
import { ServerWithId } from './data';
export interface ManageServersRowDropdownProps {
server: ServerWithId;
}
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps {
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
}
export const ManageServersRowDropdown = (
DeleteServerModal: FC<DeleteServerModalProps>,
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
const [ isMenuOpen, toggleMenu ] = useToggle();
const [ isModalOpen,, showModal, hideModal ] = useToggle();
const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
return (
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem onClick={() => setAutoConnect(server, !server.autoConnect)}>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem>
<DropdownItem divider />
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem>
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
</DropdownBtnMenu>
);
};

View File

@@ -2,10 +2,12 @@
.overview__card.overview__card {
text-align: center;
border-top: 3px solid $mainColor;
border-top: 3px solid var(--brand-color);
color: inherit;
text-decoration: none;
}
.overview__card-title {
text-transform: uppercase;
color: #6c757d;
color: $textPlaceholder;
}

View File

@@ -1,7 +1,6 @@
import { FC, useEffect } from 'react';
import { Card, CardBody, CardHeader, CardText, CardTitle } from 'reactstrap';
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
import { Link, useHistory } from 'react-router-dom';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
@@ -10,12 +9,14 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Versions } from '../utils/helpers/version';
import { isServerWithId, SelectedServer } from './data';
import { Topics } from '../mercure/helpers/Topics';
import { ShlinkShortUrlsListParams } from '../api/types';
import { getServerId, SelectedServer } from './data';
import './Overview.scss';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void;
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
listTags: Function;
tagsList: TagsList;
selectedServer: SelectedServer;
@@ -38,48 +39,54 @@ export const Overview = (
}: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount } = visitsOverview;
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const serverId = getServerId(selectedServer);
const history = useHistory();
useEffect(() => {
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
listShortUrls({ itemsPerPage: 5, orderBy: { field: 'dateCreated', dir: 'DESC' } });
listTags();
loadVisitsOverview();
}, []);
return (
<>
<div className="row mb-3">
<div className="col-md-6 col-lg-4">
<Card className="overview__card mb-2" body>
<Row>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body>
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
</Card>
</div>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/orphan-visits`}>
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
<CardText tag="h2">
<ForServerVersion minVersion="2.2.0">
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
<ForServerVersion minVersion="2.6.0">
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
</ForServerVersion>
<ForServerVersion maxVersion="2.1.*">
<small className="text-muted"><i>Shlink 2.2 is needed</i></small>
<ForServerVersion maxVersion="2.5.*">
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
</ForServerVersion>
</CardText>
</Card>
</div>
<div className="col-md-6 col-lg-4">
<Card className="overview__card mb-2" body>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}>
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
<CardText tag="h2">
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</CardText>
</Card>
</div>
<div className="col-md-12 col-lg-4">
<Card className="overview__card mb-2" body>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/manage-tags`}>
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
</Card>
</div>
</div>
<Card className="mb-4">
</Row>
<Card className="mb-3">
<CardHeader>
<span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5>
@@ -100,10 +107,10 @@ export const Overview = (
shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
className="mb-0"
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/>
</CardBody>
</Card>
</>
);
}, () => 'https://shlink.io/new-visit');
}, () => [ Topics.visits, Topics.orphanVisits ]);

View File

@@ -1,45 +1,37 @@
import { isEmpty, values } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { Link } from 'react-router-dom';
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersExporter from './services/ServersExporter';
import { isServerWithId, SelectedServer, ServersMap } from './data';
import { getServerId, SelectedServer, ServersMap } from './data';
export interface ServersDropdownProps {
servers: ServersMap;
selectedServer: SelectedServer;
}
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = values(servers);
const createServerItem = (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span>
</DropdownItem>
);
const renderServers = () => {
if (isEmpty(serversList)) {
return createServerItem;
return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
</DropdownItem>
);
}
return (
<>
{serversList.map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}`}
active={isServerWithId(selectedServer) && selectedServer.id === id}
>
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
{name}
</DropdownItem>
))}
<DropdownItem divider />
{createServerItem}
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
<DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
</DropdownItem>
</>
);

View File

@@ -18,7 +18,7 @@
}
.servers-list__server-item:hover {
background-color: $lightColor;
background-color: var(--secondary-color);
}
.servers-list__server-item-icon {
@@ -29,7 +29,7 @@
.servers-list__list-group--embedded.servers-list__list-group--embedded {
border-radius: 0;
border-top: 1px solid rgba(0, 0, 0, .125);
border-top: 1px solid var(--border-color);
@media (min-width: $mdMin) {
max-height: 220px;
@@ -40,6 +40,6 @@
.servers-list__server-item {
border: none;
border-bottom: 1px solid rgba(0, 0, 0, .125);
border-bottom: 1px solid var(--border-color);
}
}

View File

@@ -7,7 +7,7 @@ import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons
import { ServerWithId } from './data';
import './ServersListGroup.scss';
interface ServersListGroup {
interface ServersListGroupProps {
servers: ServerWithId[];
embedded?: boolean;
}
@@ -19,7 +19,7 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
</ListGroupItem>
);
const ServersListGroup: FC<ServersListGroup> = ({ servers, children, embedded = false }) => (
const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
<>
{children && <h5 className="mb-md-3">{children}</h5>}
{servers.length > 0 && (

View File

@@ -1,3 +1,6 @@
import { omit } from 'ramda';
import { SemVer } from '../../utils/helpers/version';
export interface ServerData {
name: string;
url: string;
@@ -6,10 +9,11 @@ export interface ServerData {
export interface ServerWithId extends ServerData {
id: string;
autoConnect?: boolean;
}
export interface ReachableServer extends ServerWithId {
version: string;
version: SemVer;
printableVersion: string;
}
@@ -34,7 +38,12 @@ export const isServerWithId = (server: SelectedServer | ServerWithId): server is
!!server?.hasOwnProperty('id');
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
!!server?.hasOwnProperty('printableVersion');
!!server?.hasOwnProperty('version');
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
!!server?.hasOwnProperty('serverNotFound');
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
omit<ServerWithId, 'id' | 'autoConnect'>([ 'id', 'autoConnect' ], server);

View File

@@ -0,0 +1,40 @@
import { FC, Fragment } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ServerData } from '../data';
interface DuplicatedServersModalProps {
duplicatedServers: ServerData[];
isOpen: boolean;
onDiscard: () => void;
onSave: () => void;
}
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
{ isOpen, duplicatedServers, onDiscard, onSave },
) => {
const hasMultipleServers = duplicatedServers.length > 1;
return (
<Modal centered isOpen={isOpen}>
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
<ModalBody>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul>
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? (
<Fragment key={index}>
<li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li>
</Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)}
</ul>
<span>
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
</span>
</ModalBody>
<ModalFooter>
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicated' : 'Discard'}</Button>
<Button color="primary" onClick={onSave}>Save anyway</Button>
</ModalFooter>
</Modal>
);
};

View File

@@ -0,0 +1,5 @@
.import-servers-btn__csv-select {
position: absolute;
left: -9999px;
top: -9999px;
}

View File

@@ -1,52 +1,91 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import ServersImporter from '../services/ServersImporter';
import { ServerData } from '../data';
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { complement, pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks';
import { ServersImporter } from '../services/ServersImporter';
import { ServerData, ServersMap } from '../data';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>;
export interface ImportServersBtnProps {
onImport?: () => void;
onImportError?: () => void;
onImportError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom';
className?: string;
}
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
createServers: (servers: ServerData[]) => void;
servers: ServersMap;
fileRef: Ref<HTMLInputElement>;
}
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
const serversFiltering = (servers: ServerData[]) =>
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
createServers,
servers,
fileRef,
children,
onImport = () => {},
onImportError = () => {},
}: ImportServersBtnConnectProps) => {
tooltipPlacement = 'bottom',
className = '',
}) => {
const ref = fileRef ?? useRef<HTMLInputElement>();
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
const [ isModalOpen,, showModal, hideModal ] = useToggle();
const create = pipe(createServers, onImport);
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
const createNonDuplicatedServers = pipe(
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
hideModal,
);
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
importServersFromFile(target.files?.[0])
.then(createServers)
.then(onImport)
.then(setServersToCreate)
.then(() => {
// Reset input after processing file
(target as { value: string | null }).value = null;
})
.catch(onImportError);
useEffect(() => {
if (!serversToCreate) {
return;
}
const existingServers = Object.values(servers);
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
const hasDuplicatedServers = !!duplicatedServers.length;
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
hasDuplicatedServers && showModal();
}, [ serversToCreate ]);
return (
<>
<button
type="button"
className="btn btn-outline-secondary mr-2"
id="importBtn"
onClick={() => ref.current?.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
</Button>
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
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={ref} onChange={onChange} />
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onFile} />
<DuplicatedServersModal
isOpen={isModalOpen}
duplicatedServers={duplicatedServers}
onDiscard={createNonDuplicatedServers}
onSave={createAllServers}
/>
</>
);
};

View File

@@ -4,7 +4,7 @@ import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { DeleteServerButtonProps } from '../DeleteServerButton';
import { isServerWithId, SelectedServer, ServersMap } from '../data';
import NoMenuLayout from '../../common/NoMenuLayout';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import './ServerError.scss';
interface ServerErrorProps {

View File

@@ -1,3 +1,10 @@
@import '../../utils/base';
.server-form .form-group:last-child {
margin-bottom: 0;
}
.server-form__label {
font-weight: 700;
cursor: pointer;
}

View File

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

View File

@@ -2,7 +2,7 @@ import { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import Message from '../../utils/Message';
import { isNotFoundServer, SelectedServer } from '../data';
import NoMenuLayout from '../../common/NoMenuLayout';
import { NoMenuLayout } from '../../common/NoMenuLayout';
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
selectServer: (serverId: string) => void;

View File

@@ -2,24 +2,17 @@ import { pipe, prop } from 'ramda';
import { AxiosInstance } from 'axios';
import { Dispatch } from 'redux';
import { homepage } from '../../../package.json';
import { ServerData } from '../data';
import { hasServerData, ServerData } from '../data';
import { createServers } from './servers';
const responseToServersList = pipe(
prop<any, any>('data'),
(data: any): ServerData[] => {
if (!Array.isArray(data)) {
throw new Error('Value is not an array');
}
return data as ServerData[];
},
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [],
);
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
const remoteList = await get(`${homepage}/servers.json`)
.then(responseToServersList)
.catch(() => []);
const resp = await get(`${homepage}/servers.json`);
const remoteList = responseToServersList(resp);
dispatch(createServers(remoteList));
};

View File

@@ -1,6 +1,5 @@
import { identity, memoizeWith, pipe } from 'ramda';
import { Action, Dispatch } from 'redux';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
import { SelectedServer } from '../data';
import { GetState } from '../../container/types';
@@ -53,7 +52,6 @@ export const selectServer = (
getState: GetState,
) => {
dispatch(resetSelectedServer());
dispatch(resetShortUrlParams());
const { servers } = getState();
const selectedServer = servers[serverId];

View File

@@ -1,4 +1,4 @@
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
import { v4 as uuid } from 'uuid';
import { Action } from 'redux';
import { ServerData, ServersMap, ServerWithId } from '../data';
@@ -8,12 +8,22 @@ import { buildReducer } from '../../utils/helpers/redux';
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
/* eslint-enable padding-line-between-statements */
export interface CreateServersAction extends Action<string> {
newServers: ServersMap;
}
interface DeleteServerAction extends Action<string> {
serverId: string;
}
interface SetAutoConnectAction extends Action<string> {
serverId: string;
autoConnect: boolean;
}
const initialState: ServersMap = {};
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
@@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
return assoc('id', uuid(), server);
};
export default buildReducer<ServersMap, CreateServersAction>({
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
? state
: assoc(serverId, { ...state[serverId], ...serverData }, state),
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
if (!state[serverId]) {
return state;
}
if (!autoConnect) {
return assoc(serverId, { ...state[serverId], autoConnect }, state);
}
return fromPairs(
toPairs(state).map(([ evaluatedServerId, server ]) => [
evaluatedServerId,
{ ...server, autoConnect: evaluatedServerId === serverId },
]),
);
},
}, initialState);
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
@@ -48,4 +74,10 @@ export const editServer = (serverId: string, serverData: Partial<ServerData>) =>
serverData,
});
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
type: SET_AUTO_CONNECT,
serverId: id,
autoConnect,
});

View File

@@ -1,31 +1,10 @@
import { dissoc, head, keys, values } from 'ramda';
import { values } from 'ramda';
import { CsvJson } from 'csvjson';
import LocalStorage from '../../utils/services/LocalStorage';
import { ServersMap } from '../data';
import { ServersMap, serverWithIdToServerData } from '../data';
import { saveCsv } from '../../utils/helpers/files';
const saveCsv = (window: Window, csv: string) => {
const { navigator, document } = window;
const filename = 'shlink-servers.csv';
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
// IE10 and IE11
if (navigator.msSaveBlob) {
navigator.msSaveBlob(blob, filename);
return;
}
// Modern browsers
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const SERVERS_FILENAME = 'shlink-servers.csv';
export default class ServersExporter {
public constructor(
@@ -35,18 +14,15 @@ export default class ServersExporter {
) {}
public readonly exportServers = async () => {
const servers = values(this.storage.get<ServersMap>('servers') || {}).map(dissoc('id'));
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
try {
const csv = this.csvjson.toCSV(servers, {
headers: keys(head(servers)).join(','),
});
const csv = this.csvjson.toCSV(servers, { headers: 'key' });
saveCsv(this.window, csv);
saveCsv(this.window, csv, SERVERS_FILENAME);
} catch (e) {
// FIXME Handle error
/* eslint no-console: "off" */
console.error(e);
console.error(e); // eslint-disable-line no-console
}
};
}

View File

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

View File

@@ -7,26 +7,44 @@ import DeleteServerButton from '../DeleteServerButton';
import { EditServer } from '../EditServer';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers';
import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { Overview } from '../Overview';
import ServersImporter from './ServersImporter';
import { ManageServers } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow';
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
import { ServersImporter } from './ServersImporter';
import ServersExporter from './ServersExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Components
bottle.serviceFactory(
'ManageServers',
ManageServers,
'ServersExporter',
'ImportServersBtn',
'useStateFlagTimeout',
'ManageServersRow',
);
bottle.decorator('ManageServers', connect([ 'servers' ]));
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ]));
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
@@ -36,7 +54,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
@@ -62,6 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('createServers', () => createServers);
bottle.serviceFactory('deleteServer', () => deleteServer);
bottle.serviceFactory('editServer', () => editServer);
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);

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

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

View File

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

View File

@@ -12,13 +12,16 @@ interface RealTimeUpdatesProps {
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
const RealTimeUpdates = (
const RealTimeUpdatesSettings = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => (
<SimpleCard title="Real-time updates">
<SimpleCard title="Real-time updates" className="h-100">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
Enable or disable real-time updates.
<small className="form-text text-muted">
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</small>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
@@ -31,7 +34,7 @@ const RealTimeUpdates = (
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
@@ -47,4 +50,4 @@ const RealTimeUpdates = (
</SimpleCard>
);
export default RealTimeUpdates;
export default RealTimeUpdatesSettings;

View File

@@ -1,9 +1,37 @@
import { FC } from 'react';
import NoMenuLayout from '../common/NoMenuLayout';
import { FC, ReactNode } from 'react';
import { Row } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout';
const Settings = (RealTimeUpdates: FC) => () => (
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
<>
{items.map((child, index) => (
<Row key={index}>
{child.map((subChild, subIndex) => (
<div key={subIndex} className={`col-lg-${12 / child.length} mb-3`}>
{subChild}
</div>
))}
</Row>
))}
</>
);
const Settings = (
RealTimeUpdates: FC,
ShortUrlCreation: FC,
ShortUrlsList: FC,
UserInterface: FC,
Visits: FC,
Tags: FC,
) => () => (
<NoMenuLayout>
<RealTimeUpdates />
<SettingsSections
items={[
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
[ <ShortUrlCreation />, <ShortUrlsList /> ], // eslint-disable-line react/jsx-key
[ <Tags />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
]}
/>
</NoMenuLayout>
);

View File

@@ -0,0 +1,74 @@
import { FC, ReactNode } from 'react';
import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { DropdownBtn } from '../utils/DropdownBtn';
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
interface ShortUrlCreationProps {
settings: Settings;
setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
}
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
tagFilteringMode === 'includes'
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
);
return (
<SimpleCard title="Short URLs form" className="h-100">
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.validateUrls ?? false}
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
>
Request validation on long URLs when creating new short URLs.
<small className="form-text text-muted">
The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</small>
</ToggleSwitch>
</FormGroup>
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.forwardQuery ?? true}
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
>
Make all new short URLs forward their query params to the long URL.
<small className="form-text text-muted">
The initial state of the <b>Forward query params on redirect</b> checkbox will
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
</small>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label>Tag suggestions search mode:</label>
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
<DropdownItem
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
onClick={changeTagsFilteringMode('startsWith')}
>
{tagFilteringModeText('startsWith')}
</DropdownItem>
<DropdownItem
active={shortUrlCreation.tagFilteringMode === 'includes'}
onClick={changeTagsFilteringMode('includes')}
>
{tagFilteringModeText('includes')}
</DropdownItem>
</DropdownBtn>
<small className="form-text text-muted">
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
</small>
</FormGroup>
</SimpleCard>
);
};

View File

@@ -0,0 +1,26 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
import { SimpleCard } from '../utils/SimpleCard';
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
interface ShortUrlsListProps {
settings: Settings;
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
}
export const ShortUrlsListSettings: FC<ShortUrlsListProps> = (
{ settings: { shortUrlsList }, setShortUrlsListSettings },
) => (
<SimpleCard title="Short URLs list" className="h-100">
<FormGroup className="mb-0">
<label>Default ordering for short URLs list:</label>
<OrderingDropdown
items={SHORT_URLS_ORDERABLE_FIELDS}
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
/>
</FormGroup>
</SimpleCard>
);

View File

@@ -0,0 +1,35 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
interface TagsProps {
settings: Settings;
setTagsSettings: (settings: TagsSettingsOptions) => void;
}
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
<SimpleCard title="Tags" className="h-100">
<FormGroup>
<label>Default display mode when managing tags:</label>
<TagsModeDropdown
mode={tags?.defaultMode ?? 'cards'}
renderTitle={(tagsMode) => capitalize(tagsMode)}
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
/>
<small className="form-text text-muted">Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</small>
</FormGroup>
<FormGroup className="mb-0">
<label>Default ordering for tags list:</label>
<OrderingDropdown
items={TAGS_ORDERABLE_FIELDS}
order={tags?.defaultOrdering ?? {}}
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
/>
</FormGroup>
</SimpleCard>
);

View File

@@ -0,0 +1,4 @@
.user-interface__theme-icon {
float: right;
margin-top: .25rem;
}

View File

@@ -0,0 +1,33 @@
import { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme';
import { Settings, UiSettings } from './reducers/settings';
import './UserInterfaceSettings.scss';
interface UserInterfaceProps {
settings: Settings;
setUiSettings: (settings: UiSettings) => void;
}
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
<SimpleCard title="User interface" className="h-100">
<FormGroup>
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={ui?.theme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ ...ui, theme });
changeThemeInMarkup(theme);
}}
>
Use dark theme.
</ToggleSwitch>
</FormGroup>
</SimpleCard>
);

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