Compare commits

...

413 Commits

Author SHA1 Message Date
Alejandro Celaya
6ed19af295 Merge pull request #647 from shlinkio/develop
Release 3.7.0
2022-05-14 16:59:31 +02:00
Alejandro Celaya
46f7a67b14 Tagged v3.7.0 in changelog 2022-05-14 16:51:59 +02:00
Alejandro Celaya
6f2639fd1f Merge pull request #646 from acelaya-forks/feature/short-url-filtering
Feature/short url filtering
2022-05-14 16:48:18 +02:00
Alejandro Celaya
f1568eb43b Updated changelog 2022-05-14 16:43:00 +02:00
Alejandro Celaya
aefc632ed7 Removed some default imports 2022-05-14 16:41:09 +02:00
Alejandro Celaya
bd3555db94 Fixed coding styles 2022-05-14 16:38:25 +02:00
Alejandro Celaya
ed366fa4cc Updated ShortUrlsFilteringBar test 2022-05-14 16:36:45 +02:00
Alejandro Celaya
30aeba0af2 Fixed ordering dropdown to be shorter in short URLs filter 2022-05-14 14:15:21 +02:00
Alejandro Celaya
b4c3bd16b1 Fixed colors and styles in tags selector 2022-05-14 13:05:23 +02:00
Alejandro Celaya
4b97abaf72 Improved tags filtering for short URLs, allowing to select from any existing tag 2022-05-14 12:53:02 +02:00
Alejandro Celaya
e387706a7b Updated to ExternalLink 2.0 2022-05-14 12:51:36 +02:00
Alejandro Celaya
a7cc8786c3 Migrated ExportBtn test to react testing library, and changed icon 2022-05-14 11:15:48 +02:00
Alejandro Celaya
5c0573deb7 Merge pull request #645 from acelaya-forks/feature/moar-rtl-tests
Feature/moar rtl tests
2022-05-13 20:29:59 +02:00
Alejandro Celaya
bc8956db4a Fixed coding styles 2022-05-13 20:25:32 +02:00
Alejandro Celaya
d44be88b3f Fixed ilegal rendering of trinside div in DomainRow test 2022-05-13 20:21:20 +02:00
Alejandro Celaya
64ee9a39cc Migrated HorizontalBarChart test to react testing library 2022-05-13 20:18:40 +02:00
Alejandro Celaya
4999f982e4 Migrated ChartCard test to react testing library 2022-05-13 19:59:30 +02:00
Alejandro Celaya
f3f0dbac19 Added shlinkio css coding styles 2022-05-12 22:44:12 +02:00
Alejandro Celaya
c2818645c4 Merge pull request #644 from acelaya-forks/feature/new-css-style
Feature/new css style
2022-05-11 22:37:02 +02:00
Alejandro Celaya
b0a4aee175 Updated changelog 2022-05-11 22:33:02 +02:00
Alejandro Celaya
67c45f444b Replaced adidas coding styles for CSS with a custom one based on stylelint recommended 2022-05-11 22:32:17 +02:00
Alejandro Celaya
f3570d0c9d Removed no-longer needed deps 2022-05-11 21:41:19 +02:00
Alejandro Celaya
653be14e77 Merge pull request #628 from shlinkio/dependabot/npm_and_yarn/ejs-3.1.7
Bump ejs from 3.1.6 to 3.1.7
2022-05-11 19:38:16 +02:00
Alejandro Celaya
71c8b596e7 Merge pull request #633 from shlinkio/dependabot/npm_and_yarn/kind-of-6.0.3
Bump kind-of from 6.0.2 to 6.0.3
2022-05-11 19:38:09 +02:00
Alejandro Celaya
ba45b6a7a6 Merge pull request #634 from shlinkio/dependabot/npm_and_yarn/trim-newlines-3.0.1
Bump trim-newlines from 3.0.0 to 3.0.1
2022-05-11 19:38:01 +02:00
Alejandro Celaya
cd311aeaa5 Merge pull request #635 from shlinkio/dependabot/npm_and_yarn/dns-packet-1.3.4
Bump dns-packet from 1.3.1 to 1.3.4
2022-05-11 19:37:53 +02:00
Alejandro Celaya
4f7a6d5c96 Merge pull request #636 from shlinkio/dependabot/npm_and_yarn/ansi-regex-3.0.1
Bump ansi-regex from 3.0.0 to 3.0.1
2022-05-11 19:37:42 +02:00
Alejandro Celaya
a4fb439dd4 Merge pull request #641 from acelaya-forks/feature/user-event
Feature/user event
2022-05-11 19:29:39 +02:00
Alejandro Celaya
3870752bba Replaced firEvent with userEvent as much as possible 2022-05-11 19:18:43 +02:00
Alejandro Celaya
7f059c3f3b Migrated to testing-library/user-event for complex events in tests 2022-05-10 20:54:14 +02:00
Alejandro Celaya
686fe5abbe Merge pull request #640 from acelaya-forks/feature/more-rtl-tests
Feature/more rtl tests
2022-05-09 19:53:46 +02:00
Alejandro Celaya
1f47658c3c Fixed coding styles 2022-05-09 19:47:41 +02:00
Alejandro Celaya
d582a0f9e5 Migrated DomainStatusIcon test to react testing library 2022-05-09 19:45:09 +02:00
Alejandro Celaya
b1d51a4103 Migrated ManageDomains test to react testing library 2022-05-09 19:23:35 +02:00
Alejandro Celaya
fb0adf74f3 Migrated DomainsSelector test to react testing library 2022-05-09 19:03:19 +02:00
Alejandro Celaya
0b16300a70 Migrated DomainRow test to react testing library 2022-05-09 18:38:14 +02:00
Alejandro Celaya
43302ef5a8 Migrated ShlinkLogo test to react testing library 2022-05-06 21:24:16 +02:00
Alejandro Celaya
3846ca293c Migrated SimplePaginator test to react testing library 2022-05-06 21:20:14 +02:00
Alejandro Celaya
00f154ef4e Migrated ShlinkVersionsContainer test to react testing library 2022-05-06 21:01:44 +02:00
Alejandro Celaya
4ea826ed2c Migrated ShlinkVersions test to react testing library 2022-05-06 20:25:48 +02:00
Alejandro Celaya
d327142d00 Migrated ScrollToTop test to react testing library 2022-05-06 20:13:51 +02:00
Alejandro Celaya
37adcb52cf Migrated NotFound test to react testing library 2022-05-06 19:55:25 +02:00
Alejandro Celaya
fcbb9cda12 Migrated HorizontalBarChart test to react testing library + snapshots for the events on the canvas 2022-05-06 19:46:47 +02:00
Alejandro Celaya
116c36febc Merge pull request #639 from acelaya-forks/feature/more-rtl-tests
Feature/more rtl tests
2022-05-06 19:16:07 +02:00
Alejandro Celaya
43a409fb30 Updated to stryker v6 2022-05-06 19:02:03 +02:00
Alejandro Celaya
63f26d0089 Migrated MenuLayout test to react testing library 2022-05-06 18:54:00 +02:00
Alejandro Celaya
218ea09d23 Removed no longer needed module mocking 2022-05-03 20:39:09 +02:00
Alejandro Celaya
a322886710 Migrated MainHeader test to react testing library 2022-05-03 20:36:24 +02:00
Alejandro Celaya
29182ae349 Migrated Home test to react testing library 2022-05-03 20:15:22 +02:00
Alejandro Celaya
bc3bc8dd8a Migrated ErrorHandler test to react testing library 2022-05-03 20:01:40 +02:00
Alejandro Celaya
e128b847be Migrated AsideMenu test to react testing library 2022-05-03 18:03:14 +02:00
Alejandro Celaya
f72251c125 Migrated AppUpdateBanner test to react testing library 2022-05-03 17:49:42 +02:00
Alejandro Celaya
9784cbb3ac Migrated App test to react testing library 2022-05-03 17:36:34 +02:00
Alejandro Celaya
e6f9003fb6 Merge pull request #638 from acelaya-forks/feature/more-rtl-tests
Feature/more rtl tests
2022-05-02 19:39:30 +02:00
Alejandro Celaya
e8a5eadd2b Migrated ShlinkApiError test from enzyme to react testing library 2022-05-02 19:34:33 +02:00
Alejandro Celaya
337bfc47c1 Deleted ForServerVersion component, which is no longer used 2022-05-02 19:28:07 +02:00
Alejandro Celaya
677f1da8df Removed last occurrence of ForServerVersion component, replaced by feature check 2022-05-02 19:24:57 +02:00
Alejandro Celaya
8918b1ac96 Migrated Overview test from enzyme to react testing library 2022-05-02 19:19:47 +02:00
Alejandro Celaya
0de8eb1568 Migrated ForServerVersion test from enzyme to react testing library 2022-05-02 18:58:57 +02:00
Alejandro Celaya
c00aaa9018 Migrated UseExistingIfFoundInfoIcon test from enzyme to react testing library 2022-05-02 18:54:19 +02:00
Alejandro Celaya
e837ee5225 Migrated EditServer test from enzyme to react testing library 2022-05-02 18:47:18 +02:00
dependabot[bot]
fc589a0b29 Bump ejs from 3.1.6 to 3.1.7
Bumps [ejs](https://github.com/mde/ejs) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/mde/ejs/releases)
- [Changelog](https://github.com/mde/ejs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mde/ejs/compare/v3.1.6...v3.1.7)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-02 11:38:29 +00:00
dependabot[bot]
b0769d1cf8 Bump ansi-regex from 3.0.0 to 3.0.1
Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: ansi-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-02 11:38:13 +00:00
dependabot[bot]
5befd67270 Bump dns-packet from 1.3.1 to 1.3.4
Bumps [dns-packet](https://github.com/mafintosh/dns-packet) from 1.3.1 to 1.3.4.
- [Release notes](https://github.com/mafintosh/dns-packet/releases)
- [Changelog](https://github.com/mafintosh/dns-packet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mafintosh/dns-packet/compare/v1.3.1...v1.3.4)

---
updated-dependencies:
- dependency-name: dns-packet
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-02 11:38:12 +00:00
dependabot[bot]
59bdeb67bc Bump trim-newlines from 3.0.0 to 3.0.1
Bumps [trim-newlines](https://github.com/sindresorhus/trim-newlines) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/sindresorhus/trim-newlines/releases)
- [Commits](https://github.com/sindresorhus/trim-newlines/commits)

---
updated-dependencies:
- dependency-name: trim-newlines
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-02 11:38:10 +00:00
dependabot[bot]
2d1de2cac8 Bump kind-of from 6.0.2 to 6.0.3
Bumps [kind-of](https://github.com/jonschlinkert/kind-of) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/jonschlinkert/kind-of/releases)
- [Changelog](https://github.com/jonschlinkert/kind-of/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jonschlinkert/kind-of/compare/6.0.2...6.0.3)

---
updated-dependencies:
- dependency-name: kind-of
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-02 11:38:09 +00:00
Alejandro Celaya
d3f180f270 Merge pull request #632 from acelaya-forks/feature/jest-28
Feature/jest 28
2022-05-02 13:36:58 +02:00
Alejandro Celaya
5482766d03 Updated changelog 2022-05-02 13:24:44 +02:00
Alejandro Celaya
dfbe43ef02 Added missing configs to fixt tests with Jest 28 2022-05-02 13:22:38 +02:00
Alejandro Celaya
ed7bb20bbb Updated to jest 28 2022-05-02 12:08:47 +02:00
Alejandro Celaya
57a2a03469 Merge pull request #631 from acelaya-forks/feature/react-chartjs-4
Feature/react chartjs 4
2022-05-02 11:57:04 +02:00
Alejandro Celaya
df9c1a88fc Updated changelog 2022-05-02 11:52:16 +02:00
Alejandro Celaya
c8b78d04e2 Fixed jest config to transform react-chartjs-2 lib 2022-05-02 11:51:17 +02:00
Alejandro Celaya
c516969686 Updated to react-chartjs 4 2022-05-02 11:35:05 +02:00
Alejandro Celaya
fd4295ade8 Merge pull request #630 from acelaya-forks/feature/react-testing-lib
Feature/react testing lib
2022-05-02 11:04:12 +02:00
Alejandro Celaya
39d4f8c73d Migrated Checkbox test to rteact testing library 2022-05-02 10:21:18 +02:00
Alejandro Celaya
d9b55f1d95 Updated changelog 2022-05-02 10:03:27 +02:00
Alejandro Celaya
66d8a32f49 Migrated SimpleCard test to react testing library 2022-05-02 10:01:29 +02:00
Alejandro Celaya
8b091a7b23 Migrated ShortUrlCreationSetting test to react testing library 2022-05-02 09:48:49 +02:00
Alejandro Celaya
8edb3dc923 Migrated NonOrphanVisits test to react testing library 2022-05-01 16:52:33 +02:00
Alejandro Celaya
a93d1e821d Merge pull request #629 from acelaya-forks/feature/shlink-2.6
Feature/shlink 2.6
2022-05-01 11:07:42 +02:00
Alejandro Celaya
9c5cad7571 Updated changelog 2022-05-01 11:01:57 +02:00
Alejandro Celaya
b970b38c29 Removed check on title support 2022-05-01 11:00:46 +02:00
Alejandro Celaya
763ef207f1 Removed conditional orphan visits section 2022-05-01 10:50:06 +02:00
Alejandro Celaya
a949ec9e8e Removed checks for QR code features that were not supported on versions older than 2.6 2022-05-01 10:44:12 +02:00
Alejandro Celaya
9518ad9bb4 Dropped support to send tags to the PATCH endpoint 2022-05-01 10:30:51 +02:00
Alejandro Celaya
5a0d67e409 Merge pull request #624 from acelaya-forks/feature/domain-visits
Feature/domain visits
2022-04-30 12:07:24 +02:00
Alejandro Celaya
e6932e7353 Created DomainVisits test 2022-04-30 12:01:50 +02:00
Alejandro Celaya
ddb08f4d2e Removed non-needed components which were just wrapping another component that could directly be used instead 2022-04-30 11:00:19 +02:00
Alejandro Celaya
368f7acd2d Updated react and react-dom to v18.1 2022-04-30 09:24:53 +02:00
Alejandro Celaya
6714f90c37 Fixed linting issues 2022-04-30 09:19:43 +02:00
Alejandro Celaya
bbaa4d0f05 Improved domainVisits reducer covering new visits for different domains and default domain 2022-04-30 09:18:53 +02:00
Alejandro Celaya
3aa990a1b0 Created domainVisits reducer test 2022-04-30 09:11:30 +02:00
Alejandro Celaya
464ee11d0d Added react testing library and add test for DomainDropdown 2022-04-30 09:05:12 +02:00
Alejandro Celaya
f909d38130 Updated changelog 2022-04-24 18:55:34 +02:00
Alejandro Celaya
77e59c886d Added test for ShlinkApiClient.getDomainVisits 2022-04-24 18:54:44 +02:00
Alejandro Celaya
05254326cb Implemented domain visits section 2022-04-24 18:36:25 +02:00
Alejandro Celaya
932dec3bde Added dropdown in domains section, to allow multiple options over domains 2022-04-24 13:10:04 +02:00
Alejandro Celaya
e976a0c716 Merge pull request #621 from acelaya-forks/feature/react-18
Feature/react 18
2022-04-24 11:34:17 +02:00
Alejandro Celaya
71b68562db Fixed types in Overview component test 2022-04-24 11:28:32 +02:00
Alejandro Celaya
460d2d23ce Updated changelog 2022-04-24 11:23:09 +02:00
Alejandro Celaya
6aee08b866 Ensured react-leaflet is transformed with jest, until updated to jest 28 2022-04-24 11:21:01 +02:00
Alejandro Celaya
271b19a4ec Fixed no longer implicit children prop in FunctionalComponent type 2022-04-24 10:39:11 +02:00
Alejandro Celaya
d5b1dc5bff Updated to stable packages with support for react 18 2022-04-23 11:52:39 +02:00
Alejandro Celaya
965e69c525 Fixed reference error 2022-04-03 10:10:10 +02:00
Alejandro Celaya
bd549c8642 Updated to latest react-redux beta 2022-04-03 09:43:22 +02:00
Alejandro Celaya
66edaed3a0 Updated to react 18 2022-04-02 08:58:48 +02:00
Alejandro Celaya
79831322fd Merge pull request #618 from acelaya-forks/feature/latest-error
Fixed call to an invalid function in prod envs
2022-04-02 08:41:07 +02:00
Alejandro Celaya
ed1c5a2197 Fixed call to an invalid function in prod envs 2022-04-02 08:32:54 +02:00
Alejandro Celaya
6d32379b67 Merge pull request #615 from acelaya-forks/feature/csvjson-update
Feature/csvjson update
2022-03-31 20:36:13 +02:00
Alejandro Celaya
56a62ae505 Added tests for new CSV-JSON helper functions 2022-03-31 20:32:39 +02:00
Alejandro Celaya
50072f5997 Updated changelog 2022-03-31 20:20:02 +02:00
Alejandro Celaya
e875e05538 Replaced unmaintained dependency 2022-03-31 20:18:05 +02:00
Alejandro Celaya
e2c8551baf Merge pull request #614 from shlinkio/acelaya-patch-1
Removed comment
2022-03-27 17:45:51 +02:00
Alejandro Celaya
1b0a2811e0 Removed comment 2022-03-27 17:41:29 +02:00
Alejandro Celaya
9cac4d23a7 Merge pull request #611 from acelaya-forks/feature/new-coding-style
Feature/new coding style
2022-03-27 09:48:39 +02:00
Alejandro Celaya
b323ddbd33 Temporarily disabled som a11y linting rules 2022-03-27 09:44:47 +02:00
Alejandro Celaya
98450ebec3 Removed no longer needed eslint disable comments 2022-03-26 13:07:58 +01:00
Alejandro Celaya
28a5166f56 Updated changelog 2022-03-26 12:59:54 +01:00
Alejandro Celaya
4f128b3fe8 Fixed tests 2022-03-26 12:46:32 +01:00
Alejandro Celaya
fd5060b996 Updated to latest eslint 2022-03-26 12:26:46 +01:00
Alejandro Celaya
a2df486280 Updated to airbnb coding styles 2022-03-26 12:17:42 +01:00
Alejandro Celaya
4e9b19afd1 Merge pull request #609 from acelaya-forks/feature/uneject
Feature/uneject
2022-03-25 17:48:04 +01:00
Alejandro Celaya
4d78949b8d Removed test script and used jest directly instead 2022-03-25 17:43:15 +01:00
Alejandro Celaya
13bafdc924 Disabled false positive eslint rule 2022-03-25 17:40:18 +01:00
Alejandro Celaya
ea95e8e7b5 Disabled linting while building 2022-03-25 17:27:41 +01:00
Alejandro Celaya
eaa1a2f2ca Added explicit dependency on webpack 5.70 2022-03-25 17:13:36 +01:00
Alejandro Celaya
9d6121903e Fixed some dependency references 2022-03-25 16:59:37 +01:00
Alejandro Celaya
2795bf050e Dejected project back to react-scripts, where posible 2022-03-24 21:27:33 +01:00
Alejandro Celaya
0e4e430673 Fixed margin in button 2022-03-24 20:23:46 +01:00
Alejandro Celaya
3e58d861ec Merge pull request #608 from shlinkio/develop
Release 3.6.0
2022-03-17 20:41:27 +01:00
Alejandro Celaya
2d8c2f92c4 Added v3.6.0 to changelog 2022-03-17 20:38:38 +01:00
Alejandro Celaya
56fa114f3c Merge pull request #607 from acelaya-forks/feature/export-urls
Feature/export urls
2022-03-17 20:36:11 +01:00
Alejandro Celaya
0a57390c46 Created ExportShortUrlsBtn test 2022-03-17 20:28:47 +01:00
Alejandro Celaya
ea7345b872 Updated changelog 2022-03-13 19:09:06 +01:00
Alejandro Celaya
e44520b2c2 Enhanced ReportExporter test 2022-03-13 19:07:33 +01:00
Alejandro Celaya
92ddcad753 Implemented short URLs exporting 2022-03-13 18:56:42 +01:00
Alejandro Celaya
e632c5b04f Abstracted logic to parse tags from string to array and back for the query 2022-03-13 11:14:30 +01:00
Alejandro Celaya
47d30aaa34 Created ExportBtn test 2022-03-13 11:00:45 +01:00
Alejandro Celaya
a26019ca78 Re-positioned components in short urls list for consistency with other sections 2022-03-13 10:43:57 +01:00
Alejandro Celaya
ef8db5e2cd Moved short URL ordering dropdown to ShortUrlsFilteringBar to simplify positioning 2022-03-13 10:32:27 +01:00
Alejandro Celaya
18f952f4fc Merge branch 'develop' into feature/export-urls 2022-03-13 09:56:59 +01:00
Alejandro Celaya
389f4efa4d Merge pull request #602 from acelaya-forks/feature/font-awesome-6
Updated to fontawesome 6
2022-03-13 08:44:39 +01:00
Alejandro Celaya
d1e6b052d9 Updated to fontawesome 6 2022-03-13 08:40:52 +01:00
Alejandro Celaya
7fd360495b Created button to use when anything needs to be exported 2022-03-12 20:51:30 +01:00
Alejandro Celaya
187e26810d Merge pull request #600 from acelaya-forks/feature/update-react-datepicker
Updated to latest react-datepicker major version
2022-03-11 17:27:23 +01:00
Alejandro Celaya
8a1edfe7cf Fixed usage of old module in InfoTooltip test 2022-03-11 17:23:43 +01:00
Alejandro Celaya
81d405d7be Updated to latest react-datepicker major version 2022-03-11 17:16:24 +01:00
Alejandro Celaya
c4148f0494 Fixed reactstrap 9 deprecated warnings 2022-03-11 16:37:41 +01:00
Alejandro Celaya
a8f996bec7 Merge pull request #599 from acelaya-forks/feature/update-ts
Updated to latest typescript
2022-03-11 16:25:57 +01:00
Alejandro Celaya
faa81ea1a5 Merge pull request #598 from acelaya-forks/feature/footer-alignment
Feature/footer alignment
2022-03-11 16:22:00 +01:00
Alejandro Celaya
ec360d3a28 Updated to latest typescript 2022-03-11 16:20:38 +01:00
Alejandro Celaya
749074604f Added missing parentheses 2022-03-11 16:17:44 +01:00
Alejandro Celaya
c60a6a78c8 Updated changelog 2022-03-11 16:13:54 +01:00
Alejandro Celaya
f15b803851 Created sidebar reducer test 2022-03-11 16:12:54 +01:00
Alejandro Celaya
c949359d6f Renamed sidebar actions as they make more sense 2022-03-11 16:07:17 +01:00
Alejandro Celaya
73d4707420 Ensured versions footer has proper classes based on sidebar status, not selected server 2022-03-11 16:03:15 +01:00
Alejandro Celaya
4f731d9de8 Mitigated wrong footer alignment on some server sections 2022-03-10 19:13:39 +01:00
Alejandro Celaya
2b400beb31 Merge pull request #597 from shlinkio/dependabot/npm_and_yarn/urijs-1.19.10
Bump urijs from 1.19.9 to 1.19.10
2022-03-09 07:13:12 +01:00
dependabot[bot]
a3616b56f5 Bump urijs from 1.19.9 to 1.19.10
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.9 to 1.19.10.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.9...v1.19.10)

---
updated-dependencies:
- dependency-name: urijs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-08 23:43:05 +00:00
Alejandro Celaya
65a162bdd2 Fixed linting errors 2022-03-07 20:28:46 +01:00
Alejandro Celaya
0e7c2f00d1 Merge pull request #596 from acelaya-forks/feature/update-deps
Feature/update deps
2022-03-07 18:07:02 +01:00
Alejandro Celaya
2b59d02ed9 Silenced ts errors on chart tests 2022-03-07 18:06:28 +01:00
Alejandro Celaya
45c6d3996e Updated changelog 2022-03-07 17:44:48 +01:00
Alejandro Celaya
bb7545824a Updated to compare-versions 4 2022-03-07 17:44:07 +01:00
Alejandro Celaya
feb2154257 Updated to react-datepicker 4.3 2022-03-07 17:41:59 +01:00
Alejandro Celaya
8551fcf08f Updated to react-chartjs-2 3.3 2022-03-07 17:39:03 +01:00
Alejandro Celaya
61b094ee7d Updated remaining dev deps 2022-03-07 17:29:38 +01:00
Alejandro Celaya
42714066bf Updated some dev packages 2022-03-07 17:09:54 +01:00
Alejandro Celaya
94350683bd Updated more deps 2022-03-07 16:58:16 +01:00
Alejandro Celaya
3d7950bb51 Updated some deps 2022-03-07 16:53:41 +01:00
Alejandro Celaya
ec4b777429 Merge pull request #591 from acelaya-forks/feature/bootstrap5
Feature/bootstrap5
2022-03-07 16:34:19 +01:00
Alejandro Celaya
61b61bce1c Fixed styles 2022-03-07 16:28:21 +01:00
Alejandro Celaya
dcfb5ab054 Fixed tests after bootstrap 5 update 2022-03-07 16:27:25 +01:00
Alejandro Celaya
6346f82a0a Updated target in tsconfig to es2019 2022-03-07 14:26:35 +01:00
Alejandro Celaya
31f1d5b530 Updated babel packages and extracted babel config 2022-03-07 14:21:09 +01:00
Alejandro Celaya
fc71c0f5c8 Fixed text decoration for anchors with btn class 2022-03-07 11:25:42 +01:00
Alejandro Celaya
7ab368a424 Fixed ShortUrlCreationSettings test 2022-03-07 11:17:40 +01:00
Alejandro Celaya
1cee36ec9f Updated changelog 2022-03-07 11:05:49 +01:00
Alejandro Celaya
74635281de Fixed table rendering issues 2022-03-07 11:03:41 +01:00
Alejandro Celaya
0f43ad59a0 Updated jest packages 2022-03-07 10:30:57 +01:00
Alejandro Celaya
b97ea17950 Fixed smooth scrolling which feels weird with client-side routing 2022-03-07 09:13:23 +01:00
Alejandro Celaya
3f48ca401d Fixed responsive table headers 2022-03-07 09:09:30 +01:00
Alejandro Celaya
3ecad0161b Fixed gaps in sticky tables 2022-03-07 08:55:00 +01:00
Alejandro Celaya
9ff331e2db Fixed styles in tag edition modal 2022-03-07 08:46:43 +01:00
Alejandro Celaya
27e3b6f0d0 Fixed short URL creation and modal close buttons 2022-03-06 11:16:31 +01:00
Alejandro Celaya
6a739b7a25 Fixed styles in main header 2022-03-06 10:58:30 +01:00
Alejandro Celaya
56313e5db8 Fixed home page styles for bootstrap 5 2022-03-06 10:38:26 +01:00
Alejandro Celaya
d8e4a4b891 More improvements to form controls with bootstrap 5 2022-03-05 19:57:48 +01:00
Alejandro Celaya
dee1932a64 Fixed form labels 2022-03-05 19:43:10 +01:00
Alejandro Celaya
661b9b2cc1 Fixed more rendering issues after BS5 migration 2022-03-05 16:11:01 +01:00
Alejandro Celaya
f24fb61e20 Fixed horizontal scroll 2022-03-05 15:46:26 +01:00
Alejandro Celaya
0993b43c79 Created namespace for form controls 2022-03-05 14:43:43 +01:00
Alejandro Celaya
ec403d7b1f Fixed links and some form styles 2022-03-05 14:04:01 +01:00
Alejandro Celaya
f4fa1582a7 Fixed overwritting of default bootstrap color 2022-03-05 13:32:47 +01:00
Alejandro Celaya
e5a84b1505 Updated margin, padding and alignment classes to the new bootstrap 5 approach 2022-03-05 13:26:28 +01:00
Alejandro Celaya
ce871fe2a2 Updated to latest bootstrap and reactstrap 2022-03-05 13:14:26 +01:00
Alejandro Celaya
5a713fe92f Merge pull request #588 from shlinkio/dependabot/npm_and_yarn/urijs-1.19.9
Bump urijs from 1.19.8 to 1.19.9
2022-03-04 00:57:11 +01:00
dependabot[bot]
819df9cf3d Bump urijs from 1.19.8 to 1.19.9
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.8 to 1.19.9.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.8...v1.19.9)

---
updated-dependencies:
- dependency-name: urijs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-03 23:34:50 +00:00
Alejandro Celaya
a67e0b052f Merge pull request #587 from acelaya-forks/feature/split-scripts
Split dist file creation and version replacing from main build script
2022-02-27 20:15:03 +01:00
Alejandro Celaya
c088259e46 Split dist file creation and version replacing from main build script 2022-02-27 20:10:10 +01:00
Alejandro Celaya
82f8636af5 Merge pull request #586 from acelaya-forks/feature/jest-config
Feature/jest config
2022-02-27 19:26:05 +01:00
Alejandro Celaya
f0ad4dad9f Removed ts-jest, which is not actually used 2022-02-27 19:18:59 +01:00
Alejandro Celaya
acf19823b0 Simplified jest config and removed unneeded packages 2022-02-27 19:12:30 +01:00
Alejandro Celaya
c02fba8d82 Merge pull request #578 from shlinkio/dependabot/npm_and_yarn/follow-redirects-1.14.8
Bump follow-redirects from 1.14.5 to 1.14.8
2022-02-27 13:01:53 +01:00
Alejandro Celaya
a4f36f8620 Merge pull request #569 from acelaya-forks/feature/tags-mode
Added support for tag mode on short URLs list
2022-02-26 12:06:57 +01:00
Alejandro Celaya
987c27a221 Updated changelog 2022-02-26 11:59:52 +01:00
Alejandro Celaya
248f887fb3 Added missing tests on ShortUrlsFilteringBar test 2022-02-26 11:55:35 +01:00
Alejandro Celaya
8fd07070b8 Created TooltipToggleSwitch test 2022-02-26 11:25:40 +01:00
Alejandro Celaya
45c918f4ee Merge branch 'develop' into feature/tags-mode 2022-02-26 10:48:42 +01:00
Alejandro Celaya
4f267a0275 Merge pull request #584 from shlinkio/dependabot/npm_and_yarn/url-parse-1.5.10
Bump url-parse from 1.5.7 to 1.5.10
2022-02-26 06:42:15 +01:00
dependabot[bot]
ad1caaf5dd Bump url-parse from 1.5.7 to 1.5.10
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-26 05:34:34 +00:00
Alejandro Celaya
1e0528fca0 Merge pull request #583 from shlinkio/dependabot/npm_and_yarn/urijs-1.19.8
Bump urijs from 1.19.2 to 1.19.8
2022-02-26 06:32:48 +01:00
dependabot[bot]
b30df582f2 Bump urijs from 1.19.2 to 1.19.8
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.2 to 1.19.8.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.2...v1.19.8)

---
updated-dependencies:
- dependency-name: urijs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-26 03:31:29 +00:00
Alejandro Celaya
f0b42cdc09 Added missing prop 2022-02-22 19:23:57 +01:00
Alejandro Celaya
308660287e Merged develop 2022-02-22 19:16:04 +01:00
Alejandro Celaya
c80a8e9601 Merge pull request #581 from shlinkio/dependabot/npm_and_yarn/url-parse-1.5.7
Bump url-parse from 1.4.7 to 1.5.7
2022-02-19 13:46:55 +01:00
dependabot[bot]
059d17f8d6 Bump url-parse from 1.4.7 to 1.5.7
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.4.7 to 1.5.7.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.4.7...1.5.7)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-19 12:42:32 +00:00
Alejandro Celaya
de027eccad Merge pull request #580 from acelaya-forks/feature/simple-color-picker
Feature/simple color picker
2022-02-16 20:14:22 +01:00
Alejandro Celaya
643494a54b Updated changelog 2022-02-16 20:08:48 +01:00
Alejandro Celaya
71a010d5d7 Replaced rect-color with react-colorful 2022-02-16 20:07:10 +01:00
dependabot[bot]
b419586504 Bump follow-redirects from 1.14.5 to 1.14.8
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.5 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.5...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 19:41:02 +00:00
Alejandro Celaya
78a519c649 Merge pull request #574 from shlinkio/dependabot/npm_and_yarn/postcss-8.2.13
Bump postcss from 8.1.7 to 8.2.13
2022-02-14 20:39:08 +01:00
Alejandro Celaya
23ee3d18a6 Merge pull request #577 from acelaya-forks/feature/enhanced-settings
Feature/enhanced settings
2022-02-14 20:37:36 +01:00
Alejandro Celaya
a6b2f1b385 Update changelog 2022-02-14 20:30:58 +01:00
Alejandro Celaya
30a71ac8b7 Improved settings section names 2022-02-14 20:29:53 +01:00
Alejandro Celaya
ae9e5a0566 Fixed tests 2022-02-14 20:04:38 +01:00
Alejandro Celaya
f24c8052a9 Improved NavPills component and added test 2022-02-14 19:58:20 +01:00
Alejandro Celaya
b0fa14fcfe Extracted nav pills to their own component for reusability 2022-02-13 20:20:20 +01:00
Alejandro Celaya
338c2a1191 Fixed conflicts 2022-02-08 19:40:51 +01:00
Alejandro Celaya
405a150a2b Merge pull request #575 from shlinkio/dependabot/npm_and_yarn/ws-6.2.2
Bump ws from 6.2.1 to 6.2.2
2022-02-07 23:15:47 +01:00
dependabot[bot]
3c402f8787 Bump ws from 6.2.1 to 6.2.2
Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/6.2.1...6.2.2)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-07 22:10:18 +00:00
dependabot[bot]
7d10efc286 Bump postcss from 8.1.7 to 8.2.13
Bumps [postcss](https://github.com/postcss/postcss) from 8.1.7 to 8.2.13.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.1.7...8.2.13)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-07 22:09:44 +00:00
Alejandro Celaya
cf5205e976 Merge pull request #573 from acelaya-forks/feature/react-router-6
Feature/react router 6
2022-02-07 23:08:30 +01:00
Alejandro Celaya
eab072831d Fixed VisitsStats test 2022-02-07 22:55:32 +01:00
Alejandro Celaya
c4e928ff09 Fixed most tests using react-router-dom hooks 2022-02-07 22:17:57 +01:00
Alejandro Celaya
97024d828e Ensured short URLs section is active regardless the page 2022-02-06 21:17:10 +01:00
Alejandro Celaya
c6e500ba71 Updated source code to react-router 6 2022-02-06 20:07:18 +01:00
Alejandro Celaya
eb39d97cc5 Fixed merge conflicts 2022-02-05 16:48:55 +01:00
Alejandro Celaya
071eaddfd1 Merge pull request #571 from acelaya-forks/feature/non-orphan-visits
Feature/non orphan visits
2022-02-05 16:47:42 +01:00
Alejandro Celaya
0eec9b185f Added test for non-orphan visits reducer 2022-02-05 16:40:48 +01:00
Alejandro Celaya
5edb62e76b Created tests for non-orphan visits components 2022-02-05 16:37:01 +01:00
Alejandro Celaya
9bc5a050eb Updated changelog 2022-02-05 13:53:54 +01:00
Alejandro Celaya
4a80f224d8 Created components and reducer to handle non-orphan visits 2022-02-05 13:53:07 +01:00
Alejandro Celaya
0608d3cf19 Improved icon in HighlightedCard 2022-02-05 13:46:24 +01:00
Alejandro Celaya
8fbe6bb17d Added changes to load orphan visits and fixed tests 2022-02-05 13:37:49 +01:00
Alejandro Celaya
60929342fb Added some feedback to know which cardsin overview pages are clickable 2022-02-05 10:46:46 +01:00
Alejandro Celaya
e0d43020dc Extracted cards in overview to their own component 2022-02-05 10:04:34 +01:00
Alejandro Celaya
2de0276195 Added support for tag mode on short URLs list 2022-01-31 10:15:25 +01:00
Alejandro Celaya
1011b062ae Merge pull request #568 from acelaya-forks/feature/delete-error-code
Improved error code check on short URL deletion
2022-01-25 19:55:21 +01:00
Alejandro Celaya
c8b530cc1a Improved error code check on short URL deletion 2022-01-25 19:51:08 +01:00
Alejandro Celaya
6e72c343ab Merge pull request #566 from acelaya-forks/feature/nil-optimization
Fixed unintended usage of false where only null or undefined should m…
2022-01-08 15:14:29 +01:00
Alejandro Celaya
1c37186461 Fixed unintended usage of false where only null or undefined should match 2022-01-08 15:09:56 +01:00
Alejandro Celaya
34a59db4cf Merge pull request #558 from Roy-Orbison/tag-legibility
Make text of light tags legible
2022-01-08 12:45:14 +01:00
Alejandro Celaya
12f61d03be Created Tag component test 2022-01-08 12:41:32 +01:00
Alejandro Celaya
aca9218f9d Added test covering ColorGenerator.isColorLightForKey 2022-01-08 12:16:31 +01:00
Alejandro Celaya
b727a704a6 Changed classes to use BEM, and fixed TS compilation errors 2022-01-08 12:06:28 +01:00
Roy-Orbison
1e03eed6c0 Make text of light tags legible 2022-01-08 11:59:38 +01:00
Alejandro Celaya
e9fcdcb049 Merge pull request #563 from shlinkio/develop
Release 3.5.1
2022-01-08 11:25:22 +01:00
Alejandro Celaya
5b7f1ef18a Merge pull request #562 from acelaya-forks/feature/autocomplete-new-tags
Fixed new tags added to new short URLs, not appearing on tags autosug…
2022-01-08 11:21:32 +01:00
Alejandro Celaya
715128a653 Fixed new tags added to new short URLs, not appearing on tags autosuggest 2022-01-08 11:14:11 +01:00
Alejandro Celaya
83fbdbb135 Merge pull request #561 from acelaya-forks/feature/overview-list
Fixed short URLs list in overview page
2022-01-08 10:55:02 +01:00
Alejandro Celaya
2e963bdc8e Fixed short URLs list in overview page 2022-01-08 10:51:34 +01:00
Alejandro Celaya
8d6e93ea4f Merge pull request #560 from acelaya-forks/feature/logo-alignment
Feature/logo alignment
2022-01-08 10:30:26 +01:00
Alejandro Celaya
112a8cdf2f Updated changelog 2022-01-08 10:24:07 +01:00
Alejandro Celaya
27476d8b23 Added missing border in welcome screen title 2022-01-08 10:22:51 +01:00
Alejandro Celaya
2ad2d69b2b Fixed Shlink logo not being vertically aligned in welcome screen 2022-01-08 10:19:20 +01:00
Alejandro Celaya
a3d6944fc1 Added Twitter follow badge to readme 2022-01-07 16:16:53 +01:00
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
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
409 changed files with 47447 additions and 28832 deletions

View File

@@ -1,7 +1,9 @@
./.github
./.stryker-tmp
./build
./coverage
./node_modules
./test
./shlink-web-client.gif
./dist
./docs

View File

@@ -1,21 +1,16 @@
{
"root": true,
"extends": [
"@shlinkio/js-coding-standard"
],
"plugins": ["jest"],
"env": {
"jest/globals": true
},
"parserOptions": {
"tsconfigRootDir": ".",
"createDefaultProgram": true
},
"globals": {
"process": true,
"setImmediate": true
"project": "./tsconfig.json"
},
"ignorePatterns": ["src/service*.ts"],
"rules": {
"complexity": "off"
"jsx-a11y/control-has-associated-label": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-static-element-interactions": "off"
}
}

View File

@@ -5,54 +5,12 @@ on:
push:
branches:
- main
- develop
jobs:
lint:
runs-on: ubuntu-20.04
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-20.04
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-20.04
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-20.04
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

View File

@@ -13,10 +13,10 @@ jobs:
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- 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: Build
run: |
npm ci && \

View File

@@ -11,12 +11,12 @@ jobs:
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}
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest
env:

View File

@@ -1,11 +1,5 @@
{
"extends": [
"stylelint-config-adidas",
"stylelint-config-adidas-bem",
"stylelint-config-recommended-scss"
],
"syntax": "scss",
"plugins": [
"stylelint-scss"
"@shlinkio/stylelint-config-css-coding-standard"
]
}

View File

@@ -4,6 +4,170 @@ 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.7.0] - 2022-05-14
### Added
* [#622](https://github.com/shlinkio/shlink-web-client/pull/622) Added support to load domain visits when consuming Shlink 3.1.0 or newer.
* [#582](https://github.com/shlinkio/shlink-web-client/pull/582) Improved filtering short URLs by tag.
Now, a new full tags selector component is available, which allows selecting any of the existing tags and also composes a toggle to filter by "any" tag or "all" tags.
### Changed
* [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18.
* [#595](https://github.com/shlinkio/shlink-web-client/pull/595) Updated to react-chartjs-2 v4.1.0.
* [#594](https://github.com/shlinkio/shlink-web-client/pull/594) Updated to a new coding standard.
* [#627](https://github.com/shlinkio/shlink-web-client/pull/627) Updated to Jest 28.
* [#603](https://github.com/shlinkio/shlink-web-client/pull/603) Migrated to new and maintained dependencies to parse CSV<->JSON.
* [#610](https://github.com/shlinkio/shlink-web-client/pull/610) Migrated to a maintained coding style for CSS.
* [#619](https://github.com/shlinkio/shlink-web-client/pull/619) Introduced react testing library, to progressively replace enzyme.
### Deprecated
* *Nothing*
### Removed
* [#623](https://github.com/shlinkio/shlink-web-client/pull/623) Dropped support for Shlink older than 2.6.0.
### Fixed
* *Nothing*
## [3.6.0] - 2022-03-17
### Added
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV.
### Changed
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.
* [#567](https://github.com/shlinkio/shlink-web-client/pull/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs.
* [#448](https://github.com/shlinkio/shlink-web-client/pull/448) Updated to bootstrap v5.
* [#524](https://github.com/shlinkio/shlink-web-client/pull/524) Updated to react-router v6.
* [#576](https://github.com/shlinkio/shlink-web-client/pull/576) Updated to fontawesome v6.
* [#579](https://github.com/shlinkio/shlink-web-client/pull/579) Replaced react-color with react-colorful.
* [#564](https://github.com/shlinkio/shlink-web-client/pull/564) Updated most of the dependencies.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#589](https://github.com/shlinkio/shlink-web-client/pull/589) Fixed alignment of shlink versions footer, by basing the logic on the presence of the sidebar instead of selected server.
## [3.5.1] - 2022-01-08
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#555](https://github.com/shlinkio/shlink-web-client/issues/555) Fixed vertical alignment in welcome screen logo.
* [#554](https://github.com/shlinkio/shlink-web-client/issues/554) Fixed behavior in overview page, where items in the list of short URLs were stripped out when creating new ones, even if the amount of short URLs was still not yet big enough.
* [#557](https://github.com/shlinkio/shlink-web-client/issues/557) Fixed new tags added to new short URLs, not appearing on tags autosuggest.
## [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*

View File

@@ -1,9 +1,8 @@
FROM node:14.17-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
RUN cd /shlink-web-client && npm ci && NODE_ENV=production npm run build
FROM nginx:1.21-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

View File

@@ -1,10 +1,11 @@
# 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)
[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io).

11
babel.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
presets: [
[
'react-app',
{
runtime: 'automatic',
typescript: true,
},
],
],
};

View File

@@ -1,101 +0,0 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
const fs = require('fs');
const path = require('path');
const paths = require('./paths');
// Make sure that including paths.js after env.js will read .env variables.
delete require.cache[require.resolve('./paths')];
const { NODE_ENV } = process.env;
if (!NODE_ENV) {
throw new Error(
'The NODE_ENV environment variable is required but was not specified.',
);
}
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
const dotenvFiles = [
`${paths.dotenv}.${NODE_ENV}.local`,
`${paths.dotenv}.${NODE_ENV}`,
// Don't include `.env.local` for `test` environment
// since normally you expect tests to produce the same
// results for everyone
NODE_ENV !== 'test' && `${paths.dotenv}.local`,
paths.dotenv,
].filter(Boolean);
// Load environment variables from .env* files. Suppress warnings using silent
// if this file is missing. dotenv will never modify any environment variables
// that have already been set. Variable expansion is supported in .env files.
// https://github.com/motdotla/dotenv
// https://github.com/motdotla/dotenv-expand
dotenvFiles.forEach((dotenvFile) => {
if (fs.existsSync(dotenvFile)) {
require('dotenv-expand')(
require('dotenv').config({
path: dotenvFile,
}),
);
}
});
// We support resolving modules according to `NODE_PATH`.
// This lets you use absolute paths in imports inside large monorepos:
// https://github.com/facebook/create-react-app/issues/253.
// It works similar to `NODE_PATH` in Node itself:
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
// We also resolve them to make sure all tools using them work consistently.
const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || '')
.split(path.delimiter)
.filter((folder) => folder && !path.isAbsolute(folder))
.map((folder) => path.resolve(appDirectory, folder))
.join(path.delimiter);
// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
// injected into the application via DefinePlugin in Webpack configuration.
const REACT_APP = /^REACT_APP_/i;
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter((key) => REACT_APP.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
// Useful for determining whether were running in production mode.
// Most importantly, it switches React into the correct mode.
NODE_ENV: process.env.NODE_ENV || 'development',
// Useful for resolving the correct path to static assets in `public`.
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// This should only be used as an escape hatch. Normally you would put
// images into the `src` and `import` them in code to get their paths.
PUBLIC_URL: publicUrl,
},
);
// Stringify all values so we can feed into Webpack DefinePlugin
const stringified = {
'process.env': Object.keys(raw).reduce((env, key) => {
env[key] = JSON.stringify(raw[key]);
return env;
}, {}),
};
return { raw, stringified };
}
module.exports = getClientEnvironment;

View File

@@ -1,10 +1,9 @@
// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process() {
return 'module.exports = {};';
return { code: 'module.exports = {};' };
},
getCacheKey() {
// The output is always the same.

View File

@@ -24,6 +24,8 @@ module.exports = {
};`;
}
return `module.exports = ${assetFilename};`;
return {
code: `module.exports = ${assetFilename};`
};
},
};

View File

@@ -0,0 +1,4 @@
import * as util from 'util';
global.TextEncoder = util.TextEncoder;
global.TextDecoder = util.TextDecoder;

View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom';
import 'jest-canvas-mock';
import ResizeObserver from 'resize-observer-polyfill';
(global as any).ResizeObserver = ResizeObserver;
(global as any).scrollTo = () => {};
(global as any).matchMedia = (media: string) => ({ matches: false, media });

View File

@@ -1,89 +0,0 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
const path = require('path');
const fs = require('fs');
const url = require('url');
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebook/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);
const envPublicUrl = process.env.PUBLIC_URL;
function ensureSlash(inputPath, needsSlash) {
const hasSlash = inputPath.endsWith('/');
if (hasSlash && !needsSlash) {
return inputPath.substr(0, inputPath.length - 1);
} else if (!hasSlash && needsSlash) {
return `${inputPath}/`;
}
return inputPath;
}
const getPublicUrl = (appPackageJson) =>
envPublicUrl || require(appPackageJson).homepage;
// We use `PUBLIC_URL` environment variable or "homepage" field to infer
// "public path" at which the app is served.
// Webpack needs to know it to put the right <script> hrefs into HTML even in
// single-page apps that may serve index.html for nested URLs like /todos/42.
// We can't use a relative path in HTML because we don't want to load something
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(appPackageJson);
const servedUrl =
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
return ensureSlash(servedUrl, true);
}
const moduleFileExtensions = [
'web.mjs',
'mjs',
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
];
// Resolve file paths in the same order as webpack
const resolveModule = (resolveFn, filePath) => {
const extension = moduleFileExtensions.find((extension) =>
fs.existsSync(resolveFn(`${filePath}.${extension}`)));
if (extension) {
return resolveFn(`${filePath}.${extension}`);
}
return resolveFn(`${filePath}.js`);
};
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp('build'),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
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

@@ -1,674 +0,0 @@
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const resolve = require('resolve');
const PnpWebpackPlugin = require('pnp-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
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');
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt');
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
const getClientEnvironment = require('./env');
const paths = require('./paths');
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
// makes for a smoother build process.
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$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
/* eslint-disable complexity */
module.exports = (webpackEnv) => {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// In development, we always serve from the root. This makes config easier.
const publicPath = isEnvProduction
? paths.servedPath
: isEnvDevelopment && '/';
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = isEnvProduction
? publicPath.slice(0, -1)
: isEnvDevelopment && '';
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
options: Object.assign(
{},
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined,
),
},
{
loader: require.resolve('css-loader'),
options: cssOptions,
},
{
// Options for PostCSS as we reference these options twice
// Adds vendor prefixing based on your specified browser support in
// package.json
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebook/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
}),
],
sourceMap: isEnvProduction && shouldUseSourceMap,
},
},
].filter(Boolean);
if (preProcessor) {
loaders.push({
loader: require.resolve(preProcessor),
options: {
sourceMap: isEnvProduction && shouldUseSourceMap,
},
});
}
return loaders;
};
return {
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? 'source-map'
: false
: isEnvDevelopment && 'cheap-module-source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: [
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
isEnvDevelopment &&
require.resolve('react-dev-utils/webpackHotDevClient'),
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
].filter(Boolean),
output: {
// The build folder.
path: isEnvProduction ? paths.appBuild : undefined,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? 'static/js/[name].[chunkhash:8].js'
: isEnvDevelopment && 'static/js/bundle.js',
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? 'static/js/[name].[chunkhash:8].chunk.js'
: isEnvDevelopment && 'static/js/[name].chunk.js',
// We inferred the "public path" (such as / or /my-project) from homepage.
// We use "/" in development.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, '/')
: isEnvDevelopment &&
((info) => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// we want terser to parse ecma 8 code. However, we don't want it
// to apply any minfication steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending futher investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2,
},
mangle: {
safari10: true,
},
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true, // eslint-disable-line @typescript-eslint/camelcase
},
},
// Use multi-process parallel running to improve the build speed
// Default number of concurrent runs: os.cpus().length - 1
parallel: true,
// Enable file caching
cache: true,
sourceMap: shouldUseSourceMap,
}),
// This is only used in production mode
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
parser: safePostCssParser,
map: shouldUseSourceMap
? {
// `inline: false` forces the sourcemap to be output into a
// separate file
inline: false,
// `annotation: true` appends the sourceMappingURL to the end of
// the css file, helping the browser find the sourcemap
annotation: true,
}
: false,
},
}),
],
// Automatically split vendor and commons
// https://twitter.com/wSokra/status/969633336732905474
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
splitChunks: {
chunks: 'all',
name: false,
},
// Keep the runtime chunk separated to enable long term caching
// https://twitter.com/wSokra/status/969679223278505985
runtimeChunk: true,
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: [ 'node_modules' ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean),
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes('ts')),
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
},
plugins: [
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
// guards against forgotten dependencies and such.
PnpWebpackPlugin,
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|mjs|jsx)$/,
enforce: 'pre',
use: [
{
options: {
formatter: require.resolve('react-dev-utils/eslintFormatter'),
eslintPath: require.resolve('eslint'),
},
loader: require.resolve('eslint-loader'),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve(
'babel-preset-react-app/webpack-overrides',
),
plugins: [
[
require.resolve('babel-plugin-named-asset-import'),
{
loaderMap: {
svg: {
ReactComponent:
'@svgr/webpack?-prettier,-svgo![path]',
},
},
},
],
],
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
cacheCompression: isEnvProduction,
compact: isEnvProduction,
},
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve('babel-loader'),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [
[
require.resolve('babel-preset-react-app/dependencies'),
{ helpers: true },
],
],
cacheDirectory: true,
cacheCompression: isEnvProduction,
// If an error happens in a package, it's possible to be
// because it was compiled. Thus, we don't want the browser
// debugger to show the original code. Instead, the code
// being evaluated would be much more helpful.
sourceMaps: false,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction && shouldUseSourceMap,
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
}),
},
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
},
'sass-loader',
),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
},
'sass-loader',
),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve('file-loader'),
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [ /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/ ],
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined,
),
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [ /runtime~.+[.]js/ ]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
// In development, this will be an empty string.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
}),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: 'asset-manifest.json',
publicPath,
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// 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({
typescript: resolve.sync('typescript', {
basedir: paths.appNodeModules,
}),
async: false,
checkSyntacticErrors: true,
tsconfig: paths.appTsConfig,
compilerOptions: {
module: 'esnext',
moduleResolution: 'node',
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
jsx: 'preserve',
},
reportFiles: [
'**',
'!**/*.json',
'!**/__tests__/**',
'!**/?(*.)(spec|test).*',
'!**/src/setupProxy.*',
'!**/src/setupTests.*',
],
watch: paths.appSrc,
silent: true,
formatter: typescriptFormatter,
}),
].filter(Boolean),
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty', // eslint-disable-line @typescript-eslint/camelcase
},
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
};
};

View File

@@ -1,116 +0,0 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
const fs = require('fs');
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware');
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
const ignoredFiles = require('react-dev-utils/ignoredFiles');
const paths = require('./paths');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const host = process.env.HOST || '0.0.0.0';
module.exports = function(proxy, allowedHost) {
return {
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
// websites from potentially accessing local content through DNS rebinding:
// https://github.com/webpack/webpack-dev-server/issues/887
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebook/create-react-app/issues/2271
// https://github.com/facebook/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
// use the `proxy` feature, it gets more dangerous because it can expose
// remote code execution vulnerabilities in backends like Django and Rails.
// So we will disable the host check normally, but enable it if you have
// specified the `proxy` setting. Finally, we let you override it if you
// really know what you're doing with a special environment variable.
disableHostCheck:
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true',
// Enable gzip compression of generated files.
compress: true,
// Silence WebpackDevServer's own logs since they're generally not useful.
// It will still show compile warnings and errors with this setting.
clientLogLevel: 'none',
// By default WebpackDevServer serves physical files from current directory
// in addition to all the virtual build products that it serves from memory.
// This is confusing because those files wont automatically be available in
// production build folder unless we copy them. However, copying the whole
// project directory is dangerous because we may expose sensitive files.
// Instead, we establish a convention that only files in `public` directory
// get served. Our build script will copy `public` into the `build` folder.
// In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
// Note that we only recommend to use `public` folder as an escape hatch
// for files like `favicon.ico`, `manifest.json`, and libraries that are
// for some reason broken when imported through Webpack. If you just want to
// use an image, put it in `src` and `import` it from JavaScript instead.
contentBase: paths.appPublic,
// By default files from `contentBase` will not trigger a page reload.
watchContentBase: true,
// Enable hot reloading server. It will provide /sockjs-node/ endpoint
// for the WebpackDevServer client so it can learn when the files were
// updated. The WebpackDevServer client is included as an entry point
// in the Webpack development configuration. Note that only changes
// to CSS are currently hot reloaded. JS changes will refresh the browser.
hot: true,
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: '/',
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
quiet: true,
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebook/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebook/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
// Enable HTTPS if the HTTPS environment variable is set to 'true'
https: protocol === 'https',
host,
overlay: false,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebook/create-react-app/issues/387.
disableDotRule: true,
},
public: allowedHost,
proxy,
before(app, server) {
if (fs.existsSync(paths.proxySetup)) {
// This registers user provided middleware for proxy reasons
require(paths.proxySetup)(app);
}
// This lets us fetch source contents from webpack for the error overlay
app.use(evalSourceMapMiddleware(server));
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
// This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware(paths.publicUrl));
},
};
};

View File

@@ -3,7 +3,7 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:14.17-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

@@ -9,43 +9,33 @@ module.exports = {
],
coverageThreshold: {
global: {
statements: 85,
branches: 75,
functions: 80,
lines: 85,
statements: 90,
branches: 80,
functions: 85,
lines: 90,
},
},
resolver: 'jest-pnp-resolver',
setupFiles: [
'react-app-polyfill/jsdom',
'<rootDir>/config/setupEnzyme.js',
],
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
setupFiles: ['<rootDir>/config/jest/setupBeforeEnzyme.js', '<rootDir>/config/jest/setupEnzyme.js'],
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
testEnvironment: 'jsdom',
testURL: 'http://localhost',
testEnvironmentOptions: {
url: 'http://localhost',
},
transform: {
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.scss$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(ts|tsx|js|json|scss)$)': '<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$',
'<rootDir>/.stryker-tmp',
'node_modules\/(?!(\@react-leaflet|react-leaflet|leaflet|react-chartjs-2|react-colorful)\/)',
'^.+\\.module\\.scss$',
],
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
'^.+\\.module\\.scss$': 'identity-obj-proxy',
'react-chartjs-2': '<rootDir>/node_modules/react-chartjs-2/dist/index.js',
'uuid': '<rootDir>/node_modules/uuid/dist/index.js',
},
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node',
],
moduleFileExtensions: ['js', 'ts', 'tsx', 'json'],
};

60782
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,158 +12,101 @@
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix",
"start": "node scripts/start.js",
"serve:build": "serve ./build",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom --colors --verbose",
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.js",
"build:dist": "npm run build && node scripts/create-dist-file.js",
"build:serve": "serve -p 5000 ./build",
"test": "jest --env=jsdom --colors --verbose",
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
"test:ci": "npm run test:coverage -- --coverageReporters=clover --ci",
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
},
"dependencies": {
"@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.1",
"bootstrap": "^4.6.0",
"@fortawesome/fontawesome-free": "^6.0.0",
"@fortawesome/fontawesome-svg-core": "^1.3.0",
"@fortawesome/free-regular-svg-icons": "^6.0.0",
"@fortawesome/free-solid-svg-icons": "^6.0.0",
"@fortawesome/react-fontawesome": "^0.1.17",
"axios": "^0.26.0",
"bootstrap": "^5.1.3",
"bottlejs": "^2.0.0",
"bowser": "^2.11.0",
"chart.js": "^3.5.1",
"classnames": "^2.2.6",
"compare-versions": "^3.6.0",
"csvjson": "^5.1.0",
"date-fns": "^2.22.1",
"event-source-polyfill": "^1.0.22",
"chart.js": "^3.7.1",
"classnames": "^2.3.1",
"compare-versions": "^4.1.3",
"csvtojson": "^2.0.10",
"date-fns": "^2.28.0",
"event-source-polyfill": "^1.0.25",
"json2csv": "^5.0.7",
"leaflet": "^1.7.1",
"promise": "^8.1.0",
"qs": "^6.9.6",
"ramda": "^0.27.1",
"react": "^17.0.1",
"react-chartjs-2": "^3.0.4",
"react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "^3.6.0",
"react-dom": "^17.0.1",
"react-external-link": "^1.2.0",
"react-leaflet": "^3.1.0",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-swipeable": "^6.0.1",
"react-tag-autocomplete": "^6.1.0",
"reactstrap": "^8.9.0",
"redux": "^4.0.5",
"redux-localstorage-simple": "^2.4.0",
"redux-thunk": "^2.3.0",
"ramda": "^0.27.2",
"react": "^18.1.0",
"react-chartjs-2": "^4.1.0",
"react-colorful": "^5.5.1",
"react-copy-to-clipboard": "^5.0.4",
"react-datepicker": "^4.7.0",
"react-dom": "^18.1.0",
"react-external-link": "^2.0.0",
"react-leaflet": "^4.0.0",
"react-redux": "^8.0.0",
"react-router-dom": "^6.3.0",
"react-swipeable": "^6.2.0",
"react-tag-autocomplete": "^6.3.0",
"reactstrap": "^9.0.1",
"redux": "^4.2.0",
"redux-localstorage-simple": "^2.4.1",
"redux-thunk": "^2.4.1",
"stream": "^0.0.2",
"uuid": "^8.3.2",
"workbox-core": "^6.1.5",
"workbox-expiration": "^6.1.5",
"workbox-precaching": "^6.1.5",
"workbox-routing": "^6.1.5",
"workbox-strategies": "^6.1.5"
"workbox-core": "^6.5.1",
"workbox-expiration": "^6.5.1",
"workbox-precaching": "^6.5.1",
"workbox-routing": "^6.5.1",
"workbox-strategies": "^6.5.1"
},
"devDependencies": {
"@babel/core": "^7.13.8",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^5.0.0",
"@stryker-mutator/jest-runner": "^5.0.0",
"@stryker-mutator/typescript-checker": "^5.0.0",
"@svgr/webpack": "^5.5.0",
"@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.8",
"@types/jest": "^26.0.20",
"@types/leaflet": "^1.5.23",
"@types/qs": "^6.9.5",
"@types/ramda": "^0.27.38",
"@types/react": "^17.0.2",
"@types/react-color": "^3.0.4",
"@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.16",
"@types/react-router-dom": "^5.1.7",
"@types/react-tag-autocomplete": "^6.1.0",
"@types/uuid": "^8.3.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
"adm-zip": "^0.4.16",
"autoprefixer": "^10.0.2",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^26.6.3",
"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",
"css-loader": "^5.0.1",
"dart-sass": "^1.25.0",
"dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0",
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
"@stryker-mutator/core": "^6.0.2",
"@stryker-mutator/jest-runner": "^6.0.2",
"@stryker-mutator/typescript-checker": "^6.0.2",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/enzyme": "^3.10.11",
"@types/jest": "^27.4.1",
"@types/json2csv": "^5.0.3",
"@types/leaflet": "^1.7.9",
"@types/qs": "^6.9.7",
"@types/ramda": "0.27.38",
"@types/react": "^18.0.8",
"@types/react-color": "^3.0.6",
"@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^18.0.3",
"@types/react-tag-autocomplete": "^6.1.1",
"@types/uuid": "^8.3.4",
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
"adm-zip": "^0.5.9",
"babel-jest": "^28.0.3",
"chalk": "^5.0.1",
"enzyme": "^3.11.0",
"eslint": "^7.13.0",
"eslint-loader": "^4.0.2",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
"fs-extra": "^9.0.1",
"html-webpack-plugin": "^4.5.0",
"eslint": "^8.12.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"jest-pnp-resolver": "^1.2.2",
"jest-resolve": "^26.6.2",
"mini-css-extract-plugin": "^1.3.1",
"object-assign": "^4.1.1",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"pnp-webpack-plugin": "^1.6.4",
"postcss": "^8.1.7",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"postcss-safe-parser": "^5.0.2",
"raf": "^3.4.1",
"react-app-polyfill": "^2.0.0",
"react-dev-utils": "^11.0.0",
"resolve": "^1.19.0",
"sass": "^1.29.0",
"sass-loader": "^10.1.0",
"serve": "^12.0.0",
"stryker-cli": "^1.0.0",
"style-loader": "^2.0.0",
"stylelint": "^13.7.2",
"stylelint-config-adidas": "^1.3.0",
"stylelint-config-adidas-bem": "^1.2.0",
"stylelint-config-recommended-scss": "^4.2.0",
"stylelint-scss": "^3.18.0",
"sw-precache-webpack-plugin": "^1.0.0",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^26.5.2",
"jest": "^28.0.3",
"jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^28.0.2",
"react-scripts": "^5.0.1",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.49.9",
"serve": "^13.0.2",
"stryker-cli": "^1.0.2",
"stylelint": "^14.8.2",
"ts-mockery": "^1.2.0",
"typescript": "^4.2.2",
"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",
"workbox-webpack-plugin": "^6.1.5"
},
"babel": {
"presets": [
[
"react-app",
{
"runtime": "automatic"
}
]
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
"typescript": "^4.6.2",
"webpack": "^5.70.0"
},
"browserslist": [
">0.2%",

View File

@@ -1,227 +0,0 @@
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', (err) => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // eslint-disable-line
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
process.exit(1);
}
// Process CLI arguments
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.includes('--stats');
const withoutDist = argv.includes('--no-dist');
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration
const config = configFactory('production');
checkBrowsers(paths.appPath, isInteractive)
.then(() =>
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appBuild))
.then((previousFileSizes) => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appBuild);
// Merge with the public folder
copyPublicFolder();
// Start the webpack build
return build(previousFileSizes);
})
.then(
({ stats, previousFileSizes, warnings }) => {
if (warnings.length) {
console.log(chalk.yellow('Compiled with warnings.\n'));
console.log(warnings.join('\n\n'));
console.log(
`\nSearch for the ${
chalk.underline(chalk.yellow('keywords'))
} to learn more about each warning.`,
);
console.log(
`To ignore, add ${
chalk.cyan('// eslint-disable-next-line')
} to the line before.\n`,
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
}
console.log('File sizes after gzip:\n');
printFileSizesAfterBuild(
stats,
previousFileSizes,
paths.appBuild,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE,
);
console.log();
},
(err) => {
console.log(chalk.red('Failed to compile.\n'));
printBuildError(err);
process.exit(1);
},
)
.then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
console.log('Creating an optimized production build...');
const compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
if (err) {
if (!err.message) {
return reject(err);
}
messages = formatWebpackMessages({
errors: [ err.message ],
warnings: [],
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true }),
);
}
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
if (messages.errors.length > 1) {
messages.errors.length = 1;
}
return reject(new Error(messages.errors.join('\n\n')));
}
if (
process.env.CI &&
(typeof process.env.CI !== 'string' ||
process.env.CI.toLowerCase() !== 'false') &&
messages.warnings.length
) {
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n' +
'Most CI servers set it automatically.\n',
),
);
return reject(new Error(messages.warnings.join('\n\n')));
}
const resolveArgs = {
stats,
previousFileSizes,
warnings: messages.warnings,
};
if (writeStatsJson) {
return bfj // eslint-disable-line promise/no-promise-in-callback
.write(`${paths.appBuild}/bundle-stats.json`, stats.toJson())
.then(() => resolve(resolveArgs))
.catch((error) => reject(new Error(error)));
}
return resolve(resolveArgs);
});
});
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appBuild, {
dereference: true,
filter: (file) => file !== paths.appHtml,
});
}
function zipDist(version) {
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip();
try {
if (fs.existsSync(versionFileName)) {
fs.unlink(versionFileName);
}
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated'));
} catch (e) {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
console.log();
}
function getVersionFromArgs(argv) {
const [ version ] = argv;
return { version, hasVersion: !!version };
}
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
}

View File

@@ -0,0 +1,34 @@
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
const chalk = require('chalk');
const AdmZip = require('adm-zip');
const fs = require('fs');
function zipDist(version) {
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip();
try {
if (fs.existsSync(versionFileName)) {
fs.unlink(versionFileName);
}
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated'));
} catch (e) {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
console.log();
}
const version = process.env.VERSION;
if (version) {
zipDist(version);
}

View File

@@ -0,0 +1,20 @@
const fs = require('fs');
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
}
const version = process.env.VERSION;
if (version) {
replaceVersionPlaceholder(version);
}

View File

@@ -1,125 +0,0 @@
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', (err) => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const fs = require('fs');
const chalk = require('chalk');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const clearConsole = require('react-dev-utils/clearConsole');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const openBrowser = require('react-dev-utils/openBrowser');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const createDevServerConfig = require('../config/webpackDevServer.config');
const useYarn = fs.existsSync(paths.yarnLockFile);
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
process.exit(1);
}
// Tools like Cloud9 rely on this.
const DEFAULT_PORT = 3000;
const PORT = parseInt(process.env.PORT) || DEFAULT_PORT;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {
console.log(
chalk.cyan(
`Attempting to bind to HOST environment variable: ${chalk.yellow(
chalk.bold(process.env.HOST),
)}`,
),
);
console.log(
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.',
);
console.log(
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`,
);
console.log();
}
checkBrowsers(paths.appPath, isInteractive)
.then(() =>
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, PORT))
.then((port) => {
if (port === null) {
// We have not found a port.
return;
}
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
const urls = prepareUrls(protocol, HOST, port);
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({ webpack, config, appName, urls, useYarn });
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig,
);
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, (err) => {
if (err) {
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
console.log(chalk.cyan('Starting the development server...\n'));
return openBrowser(urls.localUrlForBrowser);
});
[ 'SIGINT', 'SIGTERM' ].forEach((sig) => {
process.on(sig, () => {
devServer.close();
process.exit();
});
});
})
.catch((err) => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});

View File

@@ -1,23 +0,0 @@
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.PUBLIC_URL = '';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', (err) => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
// Make tests to be matched inside tests folder
const jest = require('jest');
const argumentsToRemove = 2;
const argv = process.argv.slice(argumentsToRemove);
jest.run(argv);

View File

@@ -7,11 +7,4 @@ declare module 'event-source-polyfill' {
}
}
declare module 'csvjson' {
export declare class CsvJson {
public toObject<T>(content: string): T[];
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
}
}
declare module '*.png'

View File

@@ -9,8 +9,10 @@ export interface ShlinkApiErrorProps {
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
<>
{errorData?.detail ?? fallbackMessage}
{isInvalidArgumentError(errorData) &&
<p className="mb-0">Invalid elements: [{errorData.invalidElements.join(', ')}]</p>
}
{isInvalidArgumentError(errorData) && (
<p className="mb-0">
Invalid elements: [{errorData.invalidElements.join(', ')}]
</p>
)}
</>
);

View File

@@ -1,6 +1,5 @@
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,30 +11,34 @@ import {
ShlinkVisits,
ShlinkVisitsParams,
ShlinkShortUrlData,
ShlinkDomain,
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> => {
@@ -53,10 +56,18 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/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 getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);
@@ -69,22 +80,12 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {});
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */
public readonly updateShortUrlTags = async (
shortCode: string,
domain: OptionalString,
tags: string[],
): Promise<string[]> =>
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then(({ data }) => data.tags);
public readonly updateShortUrl = async (
shortCode: string,
domain: OptionalString,
data: ShlinkShortUrlData,
edit: ShlinkShortUrlData,
): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, data)
.then(({ data }) => data);
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
public readonly listTags = async (): Promise<ShlinkTags> =>
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
@@ -107,43 +108,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);
public readonly editDomainRedirects = async (
domainRedirects: ShlinkEditDomainRedirects,
): Promise<ShlinkDomainRedirects> =>
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
try {
return await this.axios({
method,
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
headers: { 'X-Api-Key': this.apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: stringifyQuery,
});
} catch (e) {
const { response } = e;
// 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,
});
}

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,7 +46,7 @@ export interface ShlinkVisits {
export interface ShlinkVisitsOverview {
visitsCount: number;
orphanVisitsCount?: number; // Optional only for versions older than 2.6.0
orphanVisitsCount: number;
}
export interface ShlinkVisitsParams {
@@ -83,6 +83,24 @@ export interface ShlinkDomain {
export interface ShlinkDomainsResponse {
data: ShlinkDomain[];
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
}
export type TagsFilteringMode = 'all' | 'any';
export interface ShlinkShortUrlsListParams {
page?: string;
itemsPerPage?: number;
tags?: string[];
searchTerm?: string;
startDate?: string;
endDate?: string;
orderBy?: ShortUrlsOrder;
tagsMode?: TagsFilteringMode;
}
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
orderBy?: string;
}
export interface ProblemDetailsError {
@@ -90,7 +108,6 @@ export interface ProblemDetailsError {
detail: string;
title: string;
status: number;
[extraProps: string]: any;
}
@@ -100,6 +117,6 @@ export interface InvalidArgumentError extends ProblemDetailsError {
}
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
type: 'INVALID_SHORTCODE_DELETION';
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
threshold: number;
}

View File

@@ -7,4 +7,4 @@ export const isInvalidArgumentError = (error?: ProblemDetailsError): error is In
error?.type === 'INVALID_ARGUMENT';
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
error?.type === 'INVALID_SHORTCODE_DELETION';
error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION';

View File

@@ -1,6 +1,7 @@
import { useEffect, FC } from 'react';
import { Route, Switch } from 'react-router-dom';
import NotFound from '../common/NotFound';
import { Route, Routes, useLocation } 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';
@@ -16,15 +17,19 @@ interface AppProps {
appUpdated: boolean;
}
const App = (
export const App = (
MainHeader: FC,
Home: FC,
MenuLayout: FC,
CreateServer: FC,
EditServer: FC,
Settings: FC,
SettingsComp: FC,
ManageServers: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
const location = useLocation();
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) {
@@ -39,15 +44,16 @@ const App = (
<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 className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
<Routes>
<Route index element={<Home />} />
<Route path="/settings/*" element={<SettingsComp />} />
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId/*" element={<MenuLayout />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<div className="shlink-footer">
@@ -59,5 +65,3 @@ const App = (
</div>
);
};
export default App;

View File

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

View File

@@ -1,6 +1,6 @@
import Bottle from 'bottlejs';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
import App from '../App';
import { App } from '../App';
import { ConnectDecorator } from '../../container/types';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
@@ -14,9 +14,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'CreateServer',
'EditServer',
'Settings',
'ManageServers',
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
// Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);

View File

@@ -13,7 +13,7 @@ interface AppUpdateBannerProps {
}
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
const [ isUpdating,, setUpdating ] = useToggle();
const [isUpdating,, setUpdating] = useToggle();
const update = () => {
setUpdating();
forceUpdate();
@@ -24,8 +24,8 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forc
<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" /></>}
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
{isUpdating && <>Restarting...</>}
</Button>
</p>

View File

@@ -4,7 +4,7 @@
.aside-menu {
width: $asideMenuWidth;
background-color: var(--primary-color);
box-shadow: rgba(0, 0, 0, .05) 0 8px 15px;
box-shadow: rgb(0 0 0 / .05) 0 8px 15px;
position: fixed !important;
padding-top: 13px;
padding-bottom: 10px;
@@ -23,7 +23,7 @@
@media (max-width: $smMax) {
transition: left 300ms;
top: $headerHeight - 3px;
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55);
}
}

View File

@@ -8,9 +8,8 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
import classNames from 'classnames';
import { Location } from 'history';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features';
@@ -18,7 +17,6 @@ import './AsideMenu.scss';
export interface AsideMenuProps {
selectedServer: SelectedServer;
className?: string;
showOnMobile?: boolean;
}
@@ -28,8 +26,7 @@ interface AsideMenuItemProps extends NavLinkProps {
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
<NavLink
className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected"
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
to={to}
{...rest}
>
@@ -40,12 +37,13 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, showOnMobile = false }: AsideMenuProps,
) => {
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation();
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
return (
@@ -55,7 +53,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<AsideMenuItem
to={buildPath('/list-short-urls/1')}
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
>
<FontAwesomeIcon fixedWidth icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</AsideMenuItem>
@@ -77,7 +78,7 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem>
{isServerWithId(selectedServer) && (
{hasId && (
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"

View File

@@ -6,10 +6,10 @@ interface ErrorHandlerState {
hasError: boolean;
}
const ErrorHandler = (
export const ErrorHandler = (
{ location }: Window,
{ error }: Console,
) => class ErrorHandler extends Component<any, ErrorHandlerState> {
) => class extends Component<any, ErrorHandlerState> {
public constructor(props: object) {
super(props);
this.state = { hasError: false };
@@ -26,7 +26,8 @@ const ErrorHandler = (
}
public render(): ReactNode {
if (this.state.hasError) {
const { hasError } = this.state;
if (hasError) {
return (
<div className="home">
<SimpleCard className="p-4">
@@ -39,8 +40,7 @@ const ErrorHandler = (
);
}
return this.props.children;
const { children } = this.props;
return children;
}
};
export default ErrorHandler;

View File

@@ -1,9 +1,13 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
$mainCardWidth: 720px;
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
.home {
position: relative;
padding-top: 15px;
width: 100%;
@media (min-width: $mdMin) {
padding-top: 0;
@@ -11,19 +15,32 @@
}
}
.home__logo-wrapper {
padding: 1.5rem !important;
height: 100% !important;
min-height: 300px;
}
.home__logo {
@include vertical-align();
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
}
.home__main-card {
margin: 0 auto;
max-width: 720px;
max-width: $mainCardWidth;
@media (min-width: $mdMin) {
@include vertical-align();
}
}
.home__title-wrapper {
padding: 1.5rem !important;
border-bottom: 1px solid var(--border-color);
}
.home__title {
text-align: center;
font-size: 1.75rem;

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -9,25 +10,34 @@ import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
export interface HomeProps {
interface HomeProps {
servers: ServersMap;
}
const Home = ({ servers }: HomeProps) => {
export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate();
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 && navigate(`/server/${autoConnectServer.id}`);
}, []);
return (
<div className="home">
<Card className="home__main-card">
<Row noGutters>
<Row className="g-0">
<div className="col-md-5 d-none d-md-block">
<div className="p-4">
<ShlinkLogo />
<div className="home__logo-wrapper">
<div className="home__logo">
<ShlinkLogo />
</div>
</div>
</div>
<div className="col-md-7 home__servers-container">
<div className="p-4">
<div className="home__title-wrapper">
<h1 className="home__title">Welcome!</h1>
</div>
<ServersListGroup embedded servers={serversList}>
@@ -35,14 +45,14 @@ const Home = ({ servers }: HomeProps) => {
<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 to="/server/create" className="btn btn-outline-primary btn-lg me-2">
<FontAwesomeIcon icon={faPlus} /> <span className="ms-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} />
<span className="me-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
</p>
@@ -55,5 +65,3 @@ const Home = ({ servers }: HomeProps) => {
</div>
);
};
export default Home;

View File

@@ -1,19 +1,19 @@
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import classNames from 'classnames';
import { RouteComponentProps } from 'react-router';
import { useToggle } from '../utils/helpers/hooks';
import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss';
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
const [ isOpen, toggleOpen, , close ] = useToggle();
export const MainHeader = (ServersDropdown: FC) => () => {
const [isOpen, toggleOpen, , close] = useToggle();
const location = useLocation();
const { pathname } = location;
useEffect(close, [ location ]);
useEffect(close, [location]);
const settingsPath = '/settings';
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
@@ -29,9 +29,9 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
</NavbarToggler>
<Collapse navbar isOpen={isOpen}>
<Nav navbar className="ml-auto">
<Nav navbar className="ms-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
@@ -41,5 +41,3 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
</Navbar>
);
};
export default MainHeader;

View File

@@ -1,13 +1,7 @@
@import '../utils/base';
.menu-layout__swipeable {
$offset: 15px;
height: 100%;
margin-right: -$offset;
margin-left: -$offset;
padding-left: $offset;
padding-right: $offset;
}
.menu-layout__swipeable-inner {
@@ -22,7 +16,7 @@
z-index: 1035;
font-size: 1.5rem;
cursor: pointer;
color: rgba(255, 255, 255, .5);
color: rgb(255 255 255 / .5);
@media (max-width: $smMax) {
display: inline-block;

View File

@@ -1,39 +1,54 @@
import { FC, useEffect } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
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 { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data';
import NotFound from './NotFound';
import { NotFound } from './NotFound';
import { AsideMenuProps } from './AsideMenu';
import './MenuLayout.scss';
const MenuLayout = (
interface MenuLayoutProps {
sidebarPresent: Function;
sidebarNotPresent: Function;
}
export const MenuLayout = (
TagsList: FC,
ShortUrls: FC,
ShortUrlsList: FC,
AsideMenu: FC<AsideMenuProps>,
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
DomainVisits: FC,
OrphanVisits: FC,
NonOrphanVisits: FC,
ServerError: FC,
Overview: FC,
EditShortUrl: FC,
ManageDomains: FC,
) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
const location = useLocation();
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
const showContent = isReachableServer(selectedServer);
useEffect(() => hideSidebar(), [ location ]);
useEffect(() => hideSidebar(), [location]);
useEffect(() => {
showContent && sidebarPresent();
if (!isReachableServer(selectedServer)) {
return () => sidebarNotPresent();
}, []);
if (!showContent) {
return <ServerError />;
}
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
@@ -46,21 +61,24 @@ const MenuLayout = (
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="menu-layout__container" onClick={() => hideSidebar()}>
<div className="container-xl">
<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/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
<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} />}
<Routes>
<Route index element={<Navigate replace to="overview" />} />
<Route path="/overview" element={<Overview />} />
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
<Route path="/create-short-url" element={<CreateShortUrl />} />
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} />
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
path="*"
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Switch>
</Routes>
</div>
</div>
</div>
@@ -68,5 +86,3 @@ const MenuLayout = (
</>
);
}, ServerError);
export default MenuLayout;

View File

@@ -1,6 +1,6 @@
import { FC } from 'react';
import { FC, PropsWithChildren } 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<PropsWithChildren<unknown>> = ({ children }) => (
<div className="no-menu-wrapper container-xl">{children}</div>
);

View File

@@ -1,12 +1,10 @@
import { FC } from 'react';
import { FC, PropsWithChildren } from 'react';
import { Link } from 'react-router-dom';
import { SimpleCard } from '../utils/SimpleCard';
interface NotFoundProps {
to?: string;
}
type NotFoundProps = PropsWithChildren<{ to?: string }>;
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<div className="home">
<SimpleCard className="p-4">
<h2>Oops! We could not find requested route.</h2>
@@ -19,5 +17,3 @@ const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
</SimpleCard>
</div>
);
export default NotFound;

View File

@@ -1,12 +1,12 @@
import { PropsWithChildren, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import { FC, PropsWithChildren, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export const ScrollToTop: FC<PropsWithChildren<unknown>> = ({ children }) => {
const location = useLocation();
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
useEffect(() => {
scrollTo(0, 0);
}, [ location ]);
}, [location]);
return <>{children}</>;
};
export default ScrollToTop;

View File

@@ -1,13 +1,13 @@
import { pipe } from 'ramda';
import { ExternalLink } from 'react-external-link';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
import { isReachableServer } from '../servers/data';
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
import { isReachableServer, SelectedServer } from '../servers/data';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
export interface ShlinkVersionsProps {
selectedServer: SelectedServer;
clientVersion?: string;
}
@@ -22,9 +22,9 @@ const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERS
return (
<small className="text-muted">
{isReachableServer(selectedServer) &&
{isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
}
)}
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
</small>
);

View File

@@ -1,6 +1,6 @@
@import '../utils/base';
.shlink-versions-container--with-server {
.shlink-versions-container--with-sidebar {
margin-left: 0;
@media (min-width: $mdMin) {

View File

@@ -1,15 +1,17 @@
import classNames from 'classnames';
import { isReachableServer, SelectedServer } from '../servers/data';
import { SelectedServer } from '../servers/data';
import ShlinkVersions from './ShlinkVersions';
import { Sidebar } from './reducers/sidebar';
import './ShlinkVersionsContainer.scss';
export interface ShlinkVersionsContainerProps {
selectedServer: SelectedServer;
sidebar: Sidebar;
}
const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
const classes = classNames('text-center', {
'shlink-versions-container--with-server': isReachableServer(selectedServer),
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
});
return (

View File

@@ -17,7 +17,9 @@ interface SimplePaginatorProps {
centered?: boolean;
}
const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
export const SimplePaginator: FC<SimplePaginatorProps> = (
{ pagesCount, currentPage, setCurrentPage, centered = true },
) => {
if (pagesCount < 2) {
return null;
}
@@ -35,7 +37,9 @@ const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, se
disabled={pageIsEllipsis(pageNumber)}
active={currentPage === pageNumber}
>
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{prettifyPageNumber(pageNumber)}</PaginationLink>
<PaginationLink role="link" tag="span" onClick={onClick(pageNumber)}>
{prettifyPageNumber(pageNumber)}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
@@ -44,5 +48,3 @@ const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, se
</Pagination>
);
};
export default SimplePaginator;

View File

@@ -4,7 +4,7 @@
position: relative;
padding: 5px 0 0 6px;
border-radius: .3rem;
background-color: var(--input-color);
background-color: var(--primary-color);
border: 1px solid var(--input-border-color);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
@@ -16,6 +16,16 @@
cursor: text;
}
.input-group > .react-tags {
flex: 1 1 auto;
width: 1%;
min-width: 0;
}
.card .react-tags {
background-color: var(--input-color);
}
.react-tags.is-focused {
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
}
@@ -76,7 +86,7 @@
font-size: 1.25rem;
line-height: inherit;
color: var(--input-text-color);
background-color: var(--input-color);
background-color: inherit;
/* prevent autoresize overflowing the container */
max-width: 100%;
@@ -88,6 +98,10 @@
outline: none;
}
.react-tags__search-input::placeholder {
color: #6c757d;
}
.react-tags__search-input::-ms-clear {
display: none;
}
@@ -113,7 +127,7 @@
background: var(--primary-color);
border: 1px solid var(--border-color);
border-radius: .25rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, .2);
box-shadow: 0 2px 6px rgb(0 0 0 / .2);
}
.react-tags__suggestions li {

View File

@@ -0,0 +1,25 @@
import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
export interface Sidebar {
sidebarPresent: boolean;
}
type SidebarRenderedAction = Action<string>;
type SidebarNotRenderedAction = Action<string>;
const initialState: Sidebar = {
sidebarPresent: false,
};
export default buildReducer<Sidebar, SidebarRenderedAction & SidebarNotRenderedAction>({
[SIDEBAR_PRESENT]: () => ({ sidebarPresent: true }),
[SIDEBAR_NOT_PRESENT]: () => ({ sidebarPresent: false }),
}, initialState);
export const sidebarPresent = buildActionCreator(SIDEBAR_PRESENT);
export const sidebarNotPresent = buildActionCreator(SIDEBAR_NOT_PRESENT);

View File

@@ -0,0 +1,30 @@
import { NormalizedVisit } from '../../visits/types';
import { ExportableShortUrl } from '../../short-urls/data';
import { saveCsv } from '../../utils/helpers/files';
import { JsonToCsv } from '../../utils/helpers/csvjson';
export class ReportExporter {
public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {}
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
if (!visits.length) {
return;
}
this.exportCsv(filename, visits);
};
public readonly exportShortUrls = (shortUrls: ExportableShortUrl[]) => {
if (!shortUrls.length) {
return;
}
this.exportCsv('short_urls.csv', shortUrls);
};
private readonly exportCsv = (filename: string, rows: object[]) => {
const csv = this.jsonToCsv(rows);
saveCsv(this.window, csv, filename);
};
}

View File

@@ -1,59 +1,65 @@
import axios from 'axios';
import Bottle, { Decorator } from 'bottlejs';
import ScrollToTop from '../ScrollToTop';
import MainHeader from '../MainHeader';
import Home from '../Home';
import MenuLayout from '../MenuLayout';
import Bottle from 'bottlejs';
import { ScrollToTop } from '../ScrollToTop';
import { MainHeader } from '../MainHeader';
import { Home } from '../Home';
import { MenuLayout } from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
import { ErrorHandler } from '../ErrorHandler';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
// Components
bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
bottle.decorator('MainHeader', withRouter);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
bottle.serviceFactory(
'MenuLayout',
MenuLayout,
'TagsList',
'ShortUrls',
'ShortUrlsList',
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'DomainVisits',
'OrphanVisits',
'NonOrphanVisits',
'ServerError',
'Overview',
'EditShortUrl',
'ManageDomains',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer', 'sidebar']));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
// Actions
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
};
export default provideServices;

View File

@@ -1,5 +1,4 @@
import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import provideApiServices from '../api/services/provideServices';
@@ -18,10 +17,11 @@ import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
const bottle = new Bottle();
const { container } = bottle;
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
(...args: any[]) => (container[serviceName] as T)(...args) as K;
export const { container } = bottle;
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
(...args: any[]) => (cont[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
@@ -34,15 +34,13 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
);
provideAppServices(bottle, connect);
provideCommonServices(bottle, connect, withRouter);
provideCommonServices(bottle, connect);
provideApiServices(bottle);
provideShortUrlsServices(bottle, connect);
provideServersServices(bottle, connect, withRouter);
provideServersServices(bottle, connect);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);
provideMercureServices(bottle);
provideSettingsServices(bottle, connect);
provideDomainsServices(bottle, connect);
export default container;

View File

@@ -2,19 +2,21 @@ 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 isProduction = process.env.NODE_ENV === 'production';
// eslint-disable-next-line no-mixed-operators
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const localStorageConfig: RLSOptions = {
states: [ 'settings', 'servers' ],
states: ['settings', 'servers'],
namespace: 'shlink',
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

@@ -4,7 +4,6 @@ import { Settings } from '../settings/reducers/settings';
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 { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
import { TagDeletion } from '../tags/reducers/tagDelete';
import { TagEdition } from '../tags/reducers/tagEdit';
@@ -15,18 +14,21 @@ import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { VisitsInfo } from '../visits/types';
import { Sidebar } from '../common/reducers/sidebar';
import { DomainVisits } from '../visits/reducers/domainVisits';
export interface ShlinkState {
servers: ServersMap;
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsList;
shortUrlsListParams: ShortUrlsListParams;
shortUrlCreationResult: ShortUrlCreation;
shortUrlDeletion: ShortUrlDeletion;
shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits;
domainVisits: DomainVisits;
orphanVisits: VisitsInfo;
nonOrphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail;
tagsList: TagsList;
tagDelete: TagDeletion;
@@ -36,6 +38,7 @@ export interface ShlinkState {
domainsList: DomainsList;
visitsOverview: VisitsOverview;
appUpdated: boolean;
sidebar: Sidebar;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

View File

@@ -1,20 +1,20 @@
import { FC } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { FC, useEffect } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBan as forbiddenIcon,
faCheck as defaultDomainIcon,
faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
import { useToggle } from '../utils/helpers/hooks';
import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomainRedirects } from '../api/types';
import { OptionalString } from '../utils/utils';
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
import { SelectedServer } from '../servers/data';
import { Domain } from './data';
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
import { DomainDropdown } from './helpers/DomainDropdown';
interface DomainRowProps {
domain: ShlinkDomain;
domain: Domain;
defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
}
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
@@ -25,19 +25,23 @@ const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
);
const DefaultDomain: FC = () => (
<>
<FontAwesomeIcon icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
<FontAwesomeIcon fixedWidth icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
</>
);
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
const [ isOpen, toggle ] = useToggle();
const { domain: authority, isDefault, redirects } = domain;
const domainId = `domainEdit${authority.replace(/\./g, '')}`;
export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
) => {
const { domain: authority, isDefault, redirects, status } = domain;
useEffect(() => {
checkDomainHealth(domain.domain);
}, []);
return (
<tr className="responsive-table__row">
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
<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} />}
@@ -48,26 +52,12 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
</td>
<td className="responsive-table__cell text-right">
<span id={domainId}>
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
<FontAwesomeIcon icon={isDefault ? forbiddenIcon : editIcon} />
</Button>
</span>
{isDefault && (
<UncontrolledTooltip target={domainId} placement="left">
Redirects for default domain cannot be edited here.
<br />
Use config options or env vars directly on the server.
</UncontrolledTooltip>
)}
<td className="responsive-table__cell text-lg-center" data-th="Status">
<DomainStatusIcon status={status} />
</td>
<td className="responsive-table__cell text-end">
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
</td>
<EditDomainRedirectsModal
domain={domain}
isOpen={isOpen}
toggle={toggle}
editDomainRedirects={editDomainRedirects}
/>
</tr>
);
};

View File

@@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
import { InputProps } from 'reactstrap/lib/Input';
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { isEmpty, pipe } from 'ramda';
@@ -20,7 +19,7 @@ interface DomainSelectorConnectProps extends DomainSelectorProps {
}
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
const [ inputDisplayed,, showInput, hideInput ] = useToggle();
const [inputDisplayed,, showInput, hideInput] = useToggle();
const { domains } = domainsList;
const valueIsEmpty = isEmpty(value);
const unselectDomain = () => onChange('');
@@ -32,24 +31,23 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
return inputDisplayed ? (
<InputGroup>
<Input
value={value}
value={value ?? ''}
placeholder="Domain"
onChange={(e) => onChange(e.target.value)}
/>
<InputGroupAddon addonType="append">
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroupAddon>
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
aria-label="Back to domains list"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroup>
) : (
<DropdownBtn
@@ -59,11 +57,11 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
{domains.map(({ domain, isDefault }) => (
<DropdownItem
key={domain}
active={value === domain || isDefault && valueIsEmpty}
active={(value === domain || isDefault) && valueIsEmpty}
onClick={() => onChange(domain)}
>
{domain}
{isDefault && <span className="float-right text-muted">default</span>}
{isDefault && <span className="float-end text-muted">default</span>}
</DropdownItem>
))}
<DropdownItem divider />

View File

@@ -3,8 +3,9 @@ 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 { SearchField } from '../utils/SearchField';
import { ShlinkDomainRedirects } from '../api/types';
import { SelectedServer } from '../servers/data';
import { DomainsList } from './reducers/domainsList';
import { DomainRow } from './DomainRow';
@@ -12,16 +13,18 @@ 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', '' ];
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects },
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
) => {
const { filteredDomains: domains, loading, error, errorData } = domainsList;
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
useEffect(() => {
listDomains();
@@ -42,7 +45,7 @@ export const ManageDomains: FC<ManageDomainsProps> = (
return (
<SimpleCard>
<table className="table table-hover mb-0">
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
</thead>
@@ -53,7 +56,9 @@ export const ManageDomains: FC<ManageDomainsProps> = (
key={domain.domain}
domain={domain}
editDomainRedirects={editDomainRedirects}
defaultRedirects={defaultRedirects}
checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer}
/>
))}
</tbody>

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,51 @@
import { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
import { Domain } from '../data';
import { ShlinkDomainRedirects } from '../../api/types';
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
import { getServerId, SelectedServer } from '../../servers/data';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
interface DomainDropdownProps {
domain: Domain;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
selectedServer: SelectedServer;
}
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
const [isOpen, toggle] = useToggle();
const [isModalOpen, toggleModal] = useToggle();
const { isDefault } = domain;
const canBeEdited = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
const withVisits = supportsDomainVisits(selectedServer);
const serverId = getServerId(selectedServer);
return (
<DropdownBtnMenu isOpen={isOpen} toggle={toggle}>
{withVisits && (
<DropdownItem
tag={Link}
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
)}
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
</DropdownItem>
<EditDomainRedirectsModal
domain={domain}
isOpen={isModalOpen}
toggle={toggleModal}
editDomainRedirects={editDomainRedirects}
/>
</DropdownBtnMenu>
);
};

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

@@ -1,7 +1,7 @@
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 { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import { InfoTooltip } from '../../utils/InfoTooltip';
@@ -12,8 +12,8 @@ interface EditDomainRedirectsModalProps {
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
}
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
<FormGroupContainer
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
<InputFormGroup
{...rest}
required={false}
type="url"
@@ -25,9 +25,9 @@ const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast,
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(
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, {
@@ -42,20 +42,20 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
<ModalBody>
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
<InfoTooltip className="mr-2" placement="bottom">
<InfoTooltip className="me-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">
<InfoTooltip className="me-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">
<InfoTooltip className="me-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>

View File

@@ -5,11 +5,9 @@ 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;
@@ -21,13 +19,13 @@ export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder
domainRedirects: Partial<ShlinkDomainRedirects>,
) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
const { editDomainRedirects } = buildShlinkApiClient(getState);
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
try {
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
} catch (e) {
} catch (e: any) {
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
}
};

View File

@@ -1,35 +1,44 @@
import { Action, Dispatch } from 'redux';
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } 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';
/* eslint-enable padding-line-between-statements */
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
export interface DomainsList {
domains: ShlinkDomain[];
filteredDomains: 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: [],
@@ -40,15 +49,20 @@ const initialState: DomainsList = {
export type DomainsCombinedAction = ListDomainsAction
& ApiErrorAction
& FilterDomainsAction
& EditDomainRedirectsAction;
& EditDomainRedirectsAction
& ValidateDomain;
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
(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]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
...state,
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
@@ -58,6 +72,11 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
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 (
@@ -65,15 +84,45 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
getState: GetState,
) => {
dispatch({ type: LIST_DOMAINS_START });
const { listDomains } = buildShlinkApiClient(getState);
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
try {
const domains = await listDomains();
const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
defaultRedirects,
}));
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
} catch (e) {
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
} 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,6 +1,6 @@
import Bottle from 'bottlejs';
import { ConnectDecorator } from '../../container/types';
import { filterDomains, listDomains } from '../reducers/domainsList';
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects';
@@ -8,18 +8,19 @@ import { editDomainRedirects } from '../reducers/domainRedirects';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect(
[ 'domainsList' ],
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
['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

@@ -11,6 +11,10 @@
outline: none !important;
}
:root {
scroll-behavior: auto;
}
html,
body,
#root {
@@ -19,6 +23,17 @@ body,
color: var(--text-color);
}
a,
.btn-link {
text-decoration: none;
}
/* stylelint-disable-next-line selector-max-pseudo-class */
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
.btn-link:hover {
text-decoration: underline;
}
.bg-main {
background-color: $mainColor !important;
}
@@ -34,7 +49,7 @@ body,
}
.card {
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
background-color: var(--primary-color);
border-color: var(--border-color);
}
@@ -74,7 +89,8 @@ hr {
border-color: var(--table-border-color);
}
.page-link:hover {
.page-link:hover,
.page-link:focus {
background-color: var(--secondary-color);
}
@@ -98,6 +114,22 @@ hr {
}
}
/* Deprecated. Brought from bootstrap 4 */
.btn-block {
display: block;
width: 100%;
}
.btn-primary,
.btn-primary:hover,
.btn-primary:active,
.btn-primary.active,
.btn-outline-primary:hover,
.btn-outline-primary:active,
.btn-outline-primary.active, {
color: #ffffff;
}
.dropdown-item,
.dropdown-item-text {
color: var(--text-color);
@@ -115,6 +147,16 @@ hr {
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: var(--brand-color);
@@ -123,10 +165,15 @@ hr {
.close,
.close:hover,
.table,
.table-hover tbody tr:hover {
.table-hover > tbody > tr:hover > *,
.table-hover > tbody > tr > * {
color: var(--text-color);
}
.btn-close {
filter: var(--btn-close-filter);
}
.table-hover tbody tr:hover {
background-color: var(--secondary-color);
}

View File

@@ -1,11 +1,12 @@
import { render } from 'react-dom';
import { createRoot } from 'react-dom/client';
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 pack from '../package.json';
import { container } from './container';
import { store } from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
import './index.scss';
@@ -15,9 +16,9 @@ fixLeafletIcons();
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
render(
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
<Provider store={store}>
<BrowserRouter basename={homepage}>
<BrowserRouter basename={pack.homepage}>
<ErrorHandler>
<ScrollToTop>
<App />
@@ -25,14 +26,11 @@ render(
</ErrorHandler>
</BrowserRouter>
</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
store.dispatch(appUpdateAvailable());
},
});

View File

@@ -1,5 +1,6 @@
import { FC, useEffect } from 'react';
import { pipe } from 'ramda';
import { useParams } from 'react-router-dom';
import { CreateVisit } from '../../visits/types';
import { MercureInfo } from '../reducers/mercureInfo';
import { bindToMercureTopic } from './index';
@@ -12,29 +13,31 @@ export interface MercureBoundProps {
export function boundToMercureHub<T = {}>(
WrappedComponent: FC<MercureBoundProps & T>,
getTopicsForProps: (props: T) => string[],
getTopicsForProps: (props: T, routeParams: any) => string[],
) {
const pendingUpdates = new Set<CreateVisit>();
return (props: MercureBoundProps & T) => {
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
const { interval } = mercureInfo;
const params = useParams();
useEffect(() => {
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicsForProps(props), onMessage, loadMercureInfo);
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
const topics = getTopicsForProps(props, params);
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
if (!interval) {
return closeEventSource;
}
const timer = setInterval(() => {
createNewVisits([ ...pendingUpdates ]);
createNewVisits([...pendingUpdates]);
pendingUpdates.clear();
}, interval * 1000 * 60);
return pipe(() => clearInterval(timer), () => closeEventSource?.());
}, [ mercureInfo ]);
}, [mercureInfo]);
return <WrappedComponent {...props} />;
};

View File

@@ -4,11 +4,9 @@ import { GetState } from '../../container/types';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
/* eslint-enable padding-line-between-statements */
export interface MercureInfo {
token?: string;

View File

@@ -2,13 +2,14 @@ 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 shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits';
import domainVisitsReducer from '../visits/reducers/domainVisits';
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
@@ -18,19 +19,21 @@ 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 sidebarReducer from '../common/reducers/sidebar';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
servers: serversReducer,
selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer,
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
domainVisits: domainVisitsReducer,
orphanVisits: orphanVisitsReducer,
nonOrphanVisits: nonOrphanVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
@@ -40,4 +43,5 @@ export default combineReducers<ShlinkState>({
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer,
sidebar: sidebarReducer,
});

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,79 @@
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 { useNavigate } from 'react-router-dom';
import { Result } from '../utils/Result';
import NoMenuLayout from '../common/NoMenuLayout';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { StateFlagTimeout, useGoBack, 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 {
interface CreateServerProps {
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 }: CreateServerProps,
) => {
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const handleSubmit = (serverData: ServerData) => {
const navigate = useNavigate();
const goBack = useGoBack();
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 [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData | undefined>();
const save = () => {
if (!serverData) {
return;
}
const id = uuid();
createServer({ ...serverData, id });
push(`/server/${id}`);
navigate(`/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="ms-2">Create server</Button>
</ServerForm>
{(serversImported || errorImporting) && (
<div className="mt-3">
{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

@@ -1,20 +1,20 @@
import { FC } from 'react';
import { FC, PropsWithChildren } from 'react';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../utils/helpers/hooks';
import { DeleteServerModalProps } from './DeleteServerModal';
import { ServerWithId } from './data';
export interface DeleteServerButtonProps {
export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId;
className?: string;
textClassName?: string;
}
}>;
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
{ server, className, children, textClassName },
) => {
const [ isModalOpen, , showModal, hideModal ] = useToggle();
const [isModalOpen, , showModal, hideModal] = useToggle();
return (
<>

View File

@@ -1,22 +1,27 @@
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { RouterProps } from 'react-router';
import { FC } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { useNavigate } from 'react-router-dom';
import { ServerWithId } from './data';
export interface DeleteServerModalProps {
server: ServerWithId;
toggle: () => void;
isOpen: boolean;
redirectHome?: boolean;
}
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
deleteServer: (server: ServerWithId) => void;
}
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, redirectHome = true },
) => {
const navigate = useNavigate();
const closeModal = () => {
deleteServer(server);
toggle();
history.push('/');
redirectHome && navigate('/');
};
return (
@@ -32,8 +37,8 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: De
</p>
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-danger" onClick={() => closeModal()}>Delete</button>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="danger" onClick={() => closeModal()}>Delete</Button>
</ModalFooter>
</Modal>
);

View File

@@ -1,6 +1,7 @@
import { FC } from 'react';
import { Button } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { useGoBack } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data';
@@ -9,16 +10,16 @@ interface EditServerProps {
editServer: (serverId: string, serverData: ServerData) => void;
}
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
{ editServer, selectedServer, history: { push, goBack } },
) => {
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
const goBack = useGoBack();
if (!isServerWithId(selectedServer)) {
return null;
}
const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}`);
goBack();
};
return (
@@ -28,7 +29,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
initialValues={selectedServer}
onSubmit={handleSubmit}
>
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
<Button outline color="primary">Save</Button>
</ServerForm>
</NoMenuLayout>

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="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button>
)}
</div>
<div className="col-md-6 text-md-end 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 responsive-table mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th aria-label="Auto-connect" style={{ width: '50px' }} />}
<th>Name</th>
<th>Base URL</th>
<th aria-label="Options" />
</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-end">
<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

@@ -1,13 +0,0 @@
@import '../utils/base';
.overview__card.overview__card {
text-align: center;
border-top: 3px solid var(--brand-color);
color: inherit;
text-decoration: none;
}
.overview__card-title {
text-transform: uppercase;
color: $textPlaceholder;
}

View File

@@ -1,22 +1,22 @@
import { FC, useEffect } from 'react';
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 { Card, CardBody, CardHeader, Row } from 'reactstrap';
import { Link, useNavigate } from 'react-router-dom';
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
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 { Topics } from '../mercure/helpers/Topics';
import { isServerWithId, SelectedServer } from './data';
import './Overview.scss';
import { ShlinkShortUrlsListParams } from '../api/types';
import { supportsNonOrphanVisits } from '../utils/helpers/features';
import { getServerId, SelectedServer } from './data';
import { HighlightCard } from './helpers/HighlightCard';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void;
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
listTags: Function;
tagsList: TagsList;
selectedServer: SelectedServer;
@@ -27,7 +27,6 @@ interface OverviewConnectProps {
export const Overview = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
CreateShortUrl: FC<CreateShortUrlProps>,
ForServerVersion: FC<Versions>,
) => boundToMercureHub(({
shortUrlsList,
listShortUrls,
@@ -40,11 +39,12 @@ export const Overview = (
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
const history = useHistory();
const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
const navigate = useNavigate();
useEffect(() => {
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
listTags();
loadVisitsOverview();
}, []);
@@ -52,45 +52,33 @@ export const Overview = (
return (
<>
<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 className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
</HighlightCard>
</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.6.0">
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
</ForServerVersion>
<ForServerVersion maxVersion="2.5.*">
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
</ForServerVersion>
</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}>
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount)}
</HighlightCard>
</div>
<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 className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</HighlightCard>
</div>
<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 className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
</HighlightCard>
</div>
</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>
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader>
<CardBody>
<CreateShortUrl basicMode />
@@ -100,17 +88,17 @@ export const Overview = (
<CardHeader>
<span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader>
<CardBody>
<ShortUrlsTable
shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
className="mb-0"
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/>
</CardBody>
</Card>
</>
);
}, () => [ Topics.visits, Topics.orphanVisits ]);
}, () => [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 a server</span>
</DropdownItem>
);
const renderServers = () => {
if (isEmpty(serversList)) {
return createServerItem;
return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-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="ms-1">Manage servers</span>
</DropdownItem>
</>
);
@@ -48,9 +40,9 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
</DropdownToggle>
<DropdownMenu right>{renderServers()}</DropdownMenu>
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};

View File

@@ -8,7 +8,7 @@
.servers-list__list-group:not(.servers-list__list-group--embedded) {
max-width: 400px;
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
}
.servers-list__server-item.servers-list__server-item {
@@ -17,6 +17,10 @@
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item:not(:hover) {
color: $mainColor;
}
.servers-list__server-item:hover {
background-color: var(--secondary-color);
}

View File

@@ -1,4 +1,4 @@
import { FC } from 'react';
import { FC, PropsWithChildren } from 'react';
import { ListGroup, ListGroupItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
@@ -7,10 +7,10 @@ import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons
import { ServerWithId } from './data';
import './ServersListGroup.scss';
interface ServersListGroupProps {
type ServersListGroupProps = PropsWithChildren<{
servers: ServerWithId[];
embedded?: boolean;
}
}>;
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">

View File

@@ -1,3 +1,4 @@
import { omit } from 'ramda';
import { SemVer } from '../../utils/helpers/version';
export interface ServerData {
@@ -8,6 +9,7 @@ export interface ServerData {
export interface ServerWithId extends ServerData {
id: string;
autoConnect?: boolean;
}
export interface ReachableServer extends ServerWithId {
@@ -33,12 +35,15 @@ export const hasServerData = (server: SelectedServer | ServerData): server is Se
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
!!server?.hasOwnProperty('id');
!!(server as ServerWithId)?.id;
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
!!server?.hasOwnProperty('version');
!!(server as ReachableServer)?.version;
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
!!server?.hasOwnProperty('serverNotFound');
!!(server as NotFoundServer)?.serverNotFound;
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
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

@@ -1,24 +0,0 @@
import { FC } from 'react';
import { versionMatch, Versions } from '../../utils/helpers/version';
import { isReachableServer, SelectedServer } from '../data';
interface ForServerVersionProps extends Versions {
selectedServer: SelectedServer;
}
const ForServerVersion: FC<ForServerVersionProps> = ({ minVersion, maxVersion, selectedServer, children }) => {
if (!isReachableServer(selectedServer)) {
return null;
}
const { version } = selectedServer;
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
if (!matchesVersion) {
return null;
}
return <>{children}</>;
};
export default ForServerVersion;

View File

@@ -0,0 +1,21 @@
@import '../../utils/base';
.highlight-card.highlight-card {
text-align: center;
border-top: 3px solid var(--brand-color);
color: inherit;
text-decoration: none;
}
.highlight-card__link-icon {
position: absolute;
right: 5px;
bottom: 5px;
opacity: 0.1;
transform: rotate(-45deg);
}
.highlight-card__title {
text-transform: uppercase;
color: $textPlaceholder;
}

View File

@@ -0,0 +1,21 @@
import { FC, PropsWithChildren } from 'react';
import { Card, CardText, CardTitle } from 'reactstrap';
import { Link } from 'react-router-dom';
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import './HighlightCard.scss';
export type HighlightCardProps = PropsWithChildren<{
title: string;
link?: string | false;
}>;
const buildExtraProps = (link?: string | false) => (!link ? {} : { tag: Link, to: link });
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => (
<Card className="highlight-card" body {...buildExtraProps(link)}>
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
<CardText tag="h2">{children}</CardText>
</Card>
);

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, useState, useEffect, FC, PropsWithChildren } 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 {
export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => 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;
(target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign
})
.catch(onImportError);
useEffect(() => {
if (!serversToCreate) {
return;
}
const existingServers = Object.values(servers);
const dupServers = serversToCreate.filter(serversFiltering(existingServers));
const hasDuplicatedServers = !!dupServers.length;
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(dupServers);
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 +0,0 @@
.server-form .form-group:last-child {
margin-bottom: 0;
}

View File

@@ -1,40 +1,36 @@
import { FC, ReactNode, useEffect, useState } from 'react';
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
import { FC, PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
import { handleEventPreventingDefault } from '../../utils/utils';
import { ServerData } from '../data';
import { SimpleCard } from '../../utils/SimpleCard';
import './ServerForm.scss';
interface ServerFormProps {
type ServerFormProps = PropsWithChildren<{
onSubmit: (server: ServerData) => void;
initialValues?: ServerData;
title?: ReactNode;
}
const FormGroup: FC<FormGroupContainerProps> = (props) =>
<FormGroupContainer {...props} labelClassName="create-server__label" />;
}>;
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
const [ apiKey, setApiKey ] = useState('');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
useEffect(() => {
initialValues && setName(initialValues.name);
initialValues && setUrl(initialValues.url);
initialValues && setApiKey(initialValues.apiKey);
}, [ initialValues ]);
}, [initialValues]);
return (
<form className="server-form" onSubmit={handleSubmit}>
<form className="server-form" name="serverForm" onSubmit={handleSubmit}>
<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}>APIkey</FormGroup>
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
</SimpleCard>
<div className="text-right">{children}</div>
<div className="text-end">{children}</div>
</form>
);
};

View File

@@ -1,21 +1,22 @@
import { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import { useParams } from 'react-router-dom';
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 }> {
interface WithSelectedServerProps {
selectServer: (serverId: string) => void;
selectedServer: SelectedServer;
}
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
return (props: WithSelectedServerProps & T) => {
const { selectServer, selectedServer, match } = props;
const params = useParams<{ serverId: string }>();
const { selectServer, selectedServer } = props;
useEffect(() => {
selectServer(match.params.serverId);
}, [ match.params.serverId ]);
params.serverId && selectServer(params.serverId);
}, [params.serverId]);
if (!selectedServer) {
return (

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