Compare commits

...

258 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
368 changed files with 31152 additions and 45735 deletions

View File

@@ -1,4 +1,5 @@
./.github ./.github
./.stryker-tmp
./build ./build
./coverage ./coverage
./node_modules ./node_modules

View File

@@ -1,22 +1,16 @@
{ {
"root": true,
"extends": [ "extends": [
"@shlinkio/js-coding-standard" "@shlinkio/js-coding-standard"
], ],
"plugins": ["jest"],
"env": {
"jest/globals": true
},
"parserOptions": { "parserOptions": {
"tsconfigRootDir": ".", "project": "./tsconfig.json"
"createDefaultProgram": true
},
"globals": {
"process": true,
"setImmediate": true
}, },
"ignorePatterns": ["src/service*.ts"], "ignorePatterns": ["src/service*.ts"],
"rules": { "rules": {
"complexity": "off", "jsx-a11y/control-has-associated-label": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "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

@@ -16,7 +16,7 @@ jobs:
with: with:
node-version: 16.13 node-version: 16.13
- name: Generate release assets - 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 - name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest uses: docker://antonyurchenko/git-release:latest
env: env:

View File

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

View File

@@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.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 ## [3.5.1] - 2022-01-08
### Added ### Added
* *Nothing* * *Nothing*

View File

@@ -2,8 +2,7 @@ FROM node:16.13-alpine as node
COPY . /shlink-web-client COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION ${VERSION} ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \ RUN cd /shlink-web-client && npm ci && NODE_ENV=production npm run build
npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.21-alpine FROM nginx:1.21-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

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. // This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html // http://facebook.github.io/jest/docs/en/webpack.html
module.exports = { module.exports = {
process() { process() {
return 'module.exports = {};'; return { code: 'module.exports = {};' };
}, },
getCacheKey() { getCacheKey() {
// The output is always the same. // 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

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

65495
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:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix", "lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix", "lint:js:fix": "npm run lint:js -- --fix",
"start": "node scripts/start.js", "start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
"serve:build": "serve ./build", "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.js",
"build": "node scripts/build.js", "build:dist": "npm run build && node scripts/create-dist-file.js",
"test": "node scripts/test.js --env=jsdom --colors --verbose", "build:serve": "serve -p 5000 ./build",
"test": "jest --env=jsdom --colors --verbose",
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary", "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", "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": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.15.2", "@fortawesome/fontawesome-free": "^6.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.34", "@fortawesome/fontawesome-svg-core": "^1.3.0",
"@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-regular-svg-icons": "^6.0.0",
"@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^6.0.0",
"@fortawesome/react-fontawesome": "^0.1.14", "@fortawesome/react-fontawesome": "^0.1.17",
"axios": "^0.21.2", "axios": "^0.26.0",
"bootstrap": "^4.6.0", "bootstrap": "^5.1.3",
"bottlejs": "^2.0.0", "bottlejs": "^2.0.0",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"chart.js": "^3.5.1", "chart.js": "^3.7.1",
"classnames": "^2.2.6", "classnames": "^2.3.1",
"compare-versions": "^3.6.0", "compare-versions": "^4.1.3",
"csvjson": "^5.1.0", "csvtojson": "^2.0.10",
"date-fns": "^2.22.1", "date-fns": "^2.28.0",
"event-source-polyfill": "^1.0.22", "event-source-polyfill": "^1.0.25",
"json2csv": "^5.0.7",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"promise": "^8.1.0",
"qs": "^6.9.6", "qs": "^6.9.6",
"ramda": "^0.27.1", "ramda": "^0.27.2",
"react": "^17.0.1", "react": "^18.1.0",
"react-chartjs-2": "^3.0.4", "react-chartjs-2": "^4.1.0",
"react-color": "^2.19.3", "react-colorful": "^5.5.1",
"react-copy-to-clipboard": "^5.0.2", "react-copy-to-clipboard": "^5.0.4",
"react-datepicker": "^3.6.0", "react-datepicker": "^4.7.0",
"react-dom": "^17.0.1", "react-dom": "^18.1.0",
"react-external-link": "^1.2.0", "react-external-link": "^2.0.0",
"react-leaflet": "^3.1.0", "react-leaflet": "^4.0.0",
"react-redux": "^7.2.2", "react-redux": "^8.0.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^6.3.0",
"react-swipeable": "^6.0.1", "react-swipeable": "^6.2.0",
"react-tag-autocomplete": "^6.1.0", "react-tag-autocomplete": "^6.3.0",
"reactstrap": "^8.9.0", "reactstrap": "^9.0.1",
"redux": "^4.0.5", "redux": "^4.2.0",
"redux-localstorage-simple": "^2.4.0", "redux-localstorage-simple": "^2.4.1",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.4.1",
"stream": "^0.0.2",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"workbox-core": "^6.1.5", "workbox-core": "^6.5.1",
"workbox-expiration": "^6.1.5", "workbox-expiration": "^6.5.1",
"workbox-precaching": "^6.1.5", "workbox-precaching": "^6.5.1",
"workbox-routing": "^6.1.5", "workbox-routing": "^6.5.1",
"workbox-strategies": "^6.1.5" "workbox-strategies": "^6.5.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.13.8", "@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", "@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
"@babel/plugin-proposal-optional-chaining": "^7.13.8", "@stryker-mutator/core": "^6.0.2",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2", "@stryker-mutator/jest-runner": "^6.0.2",
"@stryker-mutator/core": "^5.4.1", "@stryker-mutator/typescript-checker": "^6.0.2",
"@stryker-mutator/jest-runner": "^5.4.1", "@testing-library/jest-dom": "^5.16.4",
"@stryker-mutator/typescript-checker": "^5.4.1", "@testing-library/react": "^13.1.1",
"@svgr/webpack": "^5.5.0", "@testing-library/user-event": "^14.1.1",
"@types/classnames": "^2.2.11", "@types/enzyme": "^3.10.11",
"@types/enzyme": "^3.10.10", "@types/jest": "^27.4.1",
"@types/jest": "^27.0.2", "@types/json2csv": "^5.0.3",
"@types/leaflet": "^1.5.23", "@types/leaflet": "^1.7.9",
"@types/qs": "^6.9.5", "@types/qs": "^6.9.7",
"@types/ramda": "^0.27.38", "@types/ramda": "0.27.38",
"@types/react": "^17.0.2", "@types/react": "^18.0.8",
"@types/react-color": "^3.0.4", "@types/react-color": "^3.0.6",
"@types/react-copy-to-clipboard": "^5.0.0", "@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-datepicker": "^3.1.5", "@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^17.0.1", "@types/react-dom": "^18.0.3",
"@types/react-leaflet": "^2.5.2", "@types/react-tag-autocomplete": "^6.1.1",
"@types/react-redux": "^7.1.16", "@types/uuid": "^8.3.4",
"@types/react-router-dom": "^5.1.7", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
"@types/react-tag-autocomplete": "^6.1.0", "adm-zip": "^0.5.9",
"@types/uuid": "^8.3.0", "babel-jest": "^28.0.3",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5", "chalk": "^5.0.1",
"adm-zip": "^0.4.16",
"autoprefixer": "^10.0.2",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^27.3.1",
"babel-loader": "^8.2.1",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0",
"babel-runtime": "^6.26.0",
"bfj": "^7.0.2",
"case-sensitive-paths-webpack-plugin": "^2.3.0",
"chalk": "^4.1.2",
"css-loader": "^5.0.1",
"dart-sass": "^1.25.0",
"dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"eslint": "^7.13.0", "eslint": "^8.12.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",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^27.3.1", "jest": "^28.0.3",
"jest-pnp-resolver": "^1.2.2", "jest-canvas-mock": "^2.4.0",
"jest-resolve": "^27.3.1", "jest-environment-jsdom": "^28.0.2",
"mini-css-extract-plugin": "^1.3.1", "react-scripts": "^5.0.1",
"object-assign": "^4.1.1", "resize-observer-polyfill": "^1.5.1",
"optimize-css-assets-webpack-plugin": "^5.0.4", "sass": "^1.49.9",
"pnp-webpack-plugin": "^1.6.4", "serve": "^13.0.2",
"postcss": "^8.1.7", "stryker-cli": "^1.0.2",
"postcss-flexbugs-fixes": "^4.2.1", "stylelint": "^14.8.2",
"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": "^27.0.7",
"ts-mockery": "^1.2.0", "ts-mockery": "^1.2.0",
"typescript": "^4.4.4", "typescript": "^4.6.2",
"url-loader": "^4.1.1", "webpack": "^5.70.0"
"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"
]
}, },
"browserslist": [ "browserslist": [
">0.2%", ">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' declare module '*.png'

View File

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

View File

@@ -21,7 +21,7 @@ import {
import { stringifyQuery } from '../../utils/helpers/query'; import { stringifyQuery } from '../../utils/helpers/query';
import { orderToString } from '../../utils/helpers/ordering'; import { orderToString } from '../../utils/helpers/ordering';
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : ''; const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : '');
const rejectNilProps = reject(isNil); const rejectNilProps = reject(isNil);
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
const { orderBy = {}, ...rest } = params; const { orderBy = {}, ...rest } = params;
@@ -56,10 +56,18 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query) this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits); .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> => public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query) this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
.then(({ data }) => data.visits); .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> => public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET') this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits); .then(({ data }) => data.visits);
@@ -72,25 +80,12 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {}); .then(() => {});
// eslint-disable-next-line valid-jsdoc
/**
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
*/
public readonly updateShortUrlTags = async (
shortCode: string,
domain: OptionalString,
tags: string[],
): Promise<string[]> =>
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then(({ data }) => data.tags);
public readonly updateShortUrl = async ( public readonly updateShortUrl = async (
shortCode: string, shortCode: string,
domain: OptionalString, domain: OptionalString,
data: ShlinkShortUrlData, edit: ShlinkShortUrlData,
): Promise<ShortUrl> => ): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, data) this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
.then(({ data }) => data);
public readonly listTags = async (): Promise<ShlinkTags> => public readonly listTags = async (): Promise<ShlinkTags> =>
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' }) this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })

View File

@@ -46,7 +46,7 @@ export interface ShlinkVisits {
export interface ShlinkVisitsOverview { export interface ShlinkVisitsOverview {
visitsCount: number; visitsCount: number;
orphanVisitsCount?: number; // Optional only for versions older than 2.6.0 orphanVisitsCount: number;
} }
export interface ShlinkVisitsParams { export interface ShlinkVisitsParams {
@@ -86,6 +86,8 @@ export interface ShlinkDomainsResponse {
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10 defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
} }
export type TagsFilteringMode = 'all' | 'any';
export interface ShlinkShortUrlsListParams { export interface ShlinkShortUrlsListParams {
page?: string; page?: string;
itemsPerPage?: number; itemsPerPage?: number;
@@ -94,6 +96,7 @@ export interface ShlinkShortUrlsListParams {
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
orderBy?: ShortUrlsOrder; orderBy?: ShortUrlsOrder;
tagsMode?: TagsFilteringMode;
} }
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> { export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
@@ -114,6 +117,6 @@ export interface InvalidArgumentError extends ProblemDetailsError {
} }
export interface InvalidShortUrlDeletion extends ProblemDetailsError { export interface InvalidShortUrlDeletion extends ProblemDetailsError {
type: 'INVALID_SHORTCODE_DELETION'; type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
threshold: number; threshold: number;
} }

View File

@@ -7,4 +7,4 @@ export const isInvalidArgumentError = (error?: ProblemDetailsError): error is In
error?.type === 'INVALID_ARGUMENT'; error?.type === 'INVALID_ARGUMENT';
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion => 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,7 +1,7 @@
import { useEffect, FC } from 'react'; import { useEffect, FC } from 'react';
import { Route, RouteChildrenProps, Switch } from 'react-router-dom'; import { Route, Routes, useLocation } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import NotFound from '../common/NotFound'; import { NotFound } from '../common/NotFound';
import { ServersMap } from '../servers/data'; import { ServersMap } from '../servers/data';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { changeThemeInMarkup } from '../utils/theme'; import { changeThemeInMarkup } from '../utils/theme';
@@ -9,7 +9,7 @@ import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { forceUpdate } from '../utils/helpers/sw'; import { forceUpdate } from '../utils/helpers/sw';
import './App.scss'; import './App.scss';
interface AppProps extends RouteChildrenProps { interface AppProps {
fetchServers: () => void; fetchServers: () => void;
servers: ServersMap; servers: ServersMap;
settings: Settings; settings: Settings;
@@ -17,16 +17,17 @@ interface AppProps extends RouteChildrenProps {
appUpdated: boolean; appUpdated: boolean;
} }
const App = ( export const App = (
MainHeader: FC, MainHeader: FC,
Home: FC, Home: FC,
MenuLayout: FC, MenuLayout: FC,
CreateServer: FC, CreateServer: FC,
EditServer: FC, EditServer: FC,
Settings: FC, SettingsComp: FC,
ManageServers: FC, ManageServers: FC,
ShlinkVersionsContainer: FC, ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => { ) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
const location = useLocation();
const isHome = location.pathname === '/'; const isHome = location.pathname === '/';
useEffect(() => { useEffect(() => {
@@ -44,15 +45,15 @@ const App = (
<div className="app"> <div className="app">
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}> <div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
<Switch> <Routes>
<Route exact path="/" component={Home} /> <Route index element={<Home />} />
<Route exact path="/settings" component={Settings} /> <Route path="/settings/*" element={<SettingsComp />} />
<Route exact path="/manage-servers" component={ManageServers} /> <Route path="/manage-servers" element={<ManageServers />} />
<Route exact path="/server/create" component={CreateServer} /> <Route path="/server/create" element={<CreateServer />} />
<Route exact path="/server/:serverId/edit" component={EditServer} /> <Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId" component={MenuLayout} /> <Route path="/server/:serverId/*" element={<MenuLayout />} />
<Route component={NotFound} /> <Route path="*" element={<NotFound />} />
</Switch> </Routes>
</div> </div>
<div className="shlink-footer"> <div className="shlink-footer">
@@ -64,5 +65,3 @@ const App = (
</div> </div>
); );
}; };
export default App;

View File

@@ -1,10 +1,8 @@
import { Action } from 'redux'; import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/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 APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE'; export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
/* eslint-enable padding-line-between-statements */
const initialState = false; const initialState = false;

View File

@@ -1,9 +1,9 @@
import Bottle, { Decorator } from 'bottlejs'; import Bottle from 'bottlejs';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates'; import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
import App from '../App'; import { App } from '../App';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory( bottle.serviceFactory(
'App', 'App',
@@ -17,8 +17,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ManageServers', 'ManageServers',
'ShlinkVersionsContainer', 'ShlinkVersionsContainer',
); );
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ])); bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
bottle.decorator('App', withRouter);
// Actions // Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable); bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);

View File

@@ -13,7 +13,7 @@ interface AppUpdateBannerProps {
} }
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => { export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
const [ isUpdating,, setUpdating ] = useToggle(); const [isUpdating,, setUpdating] = useToggle();
const update = () => { const update = () => {
setUpdating(); setUpdating();
forceUpdate(); forceUpdate();
@@ -24,8 +24,8 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forc
<h4 className="mb-4">This app has just been updated!</h4> <h4 className="mb-4">This app has just been updated!</h4>
<p className="mb-0"> <p className="mb-0">
Restart it to enjoy the new features. Restart it to enjoy the new features.
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}> <Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>} {!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
{isUpdating && <>Restarting...</>} {isUpdating && <>Restarting...</>}
</Button> </Button>
</p> </p>

View File

@@ -4,7 +4,7 @@
.aside-menu { .aside-menu {
width: $asideMenuWidth; width: $asideMenuWidth;
background-color: var(--primary-color); 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; position: fixed !important;
padding-top: 13px; padding-top: 13px;
padding-bottom: 10px; padding-bottom: 10px;
@@ -23,7 +23,7 @@
@media (max-width: $smMax) { @media (max-width: $smMax) {
transition: left 300ms; transition: left 300ms;
top: $headerHeight - 3px; 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'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react'; import { FC } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom'; import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { Location } from 'history';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { isServerWithId, SelectedServer } from '../servers/data'; import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features'; import { supportsDomainRedirects } from '../utils/helpers/features';
@@ -18,7 +17,6 @@ import './AsideMenu.scss';
export interface AsideMenuProps { export interface AsideMenuProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
className?: string;
showOnMobile?: boolean; showOnMobile?: boolean;
} }
@@ -28,8 +26,7 @@ interface AsideMenuItemProps extends NavLinkProps {
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => ( const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
<NavLink <NavLink
className={classNames('aside-menu__item', className)} className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
activeClassName="aside-menu__item--selected"
to={to} to={to}
{...rest} {...rest}
> >
@@ -42,11 +39,11 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
) => { ) => {
const hasId = isServerWithId(selectedServer); const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : ''; const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation();
const addManageDomainsLink = supportsDomainRedirects(selectedServer); const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', { const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
}); });
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`; const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
return ( return (
@@ -56,7 +53,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={overviewIcon} /> <FontAwesomeIcon fixedWidth icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span> <span className="aside-menu__item-text">Overview</span>
</AsideMenuItem> </AsideMenuItem>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}> <AsideMenuItem
to={buildPath('/list-short-urls/1')}
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
>
<FontAwesomeIcon fixedWidth icon={listIcon} /> <FontAwesomeIcon fixedWidth icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span> <span className="aside-menu__item-text">List short URLs</span>
</AsideMenuItem> </AsideMenuItem>

View File

@@ -6,10 +6,10 @@ interface ErrorHandlerState {
hasError: boolean; hasError: boolean;
} }
const ErrorHandler = ( export const ErrorHandler = (
{ location }: Window, { location }: Window,
{ error }: Console, { error }: Console,
) => class ErrorHandler extends Component<any, ErrorHandlerState> { ) => class extends Component<any, ErrorHandlerState> {
public constructor(props: object) { public constructor(props: object) {
super(props); super(props);
this.state = { hasError: false }; this.state = { hasError: false };
@@ -26,7 +26,8 @@ const ErrorHandler = (
} }
public render(): ReactNode { public render(): ReactNode {
if (this.state.hasError) { const { hasError } = this.state;
if (hasError) {
return ( return (
<div className="home"> <div className="home">
<SimpleCard className="p-4"> <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,6 +1,9 @@
@import '../utils/base'; @import '../utils/base';
@import '../utils/mixins/vertical-align'; @import '../utils/mixins/vertical-align';
$mainCardWidth: 720px;
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
.home { .home {
position: relative; position: relative;
padding-top: 15px; padding-top: 15px;
@@ -21,12 +24,12 @@
.home__logo { .home__logo {
@include vertical-align(); @include vertical-align();
width: calc(100% - 3rem); width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
} }
.home__main-card { .home__main-card {
margin: 0 auto; margin: 0 auto;
max-width: 720px; max-width: $mainCardWidth;
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
@include vertical-align(); @include vertical-align();

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { isEmpty, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import { Link, RouteChildrenProps } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Card, Row } from 'reactstrap'; import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -10,25 +10,25 @@ import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss'; import './Home.scss';
export interface HomeProps extends RouteChildrenProps { interface HomeProps {
servers: ServersMap; servers: ServersMap;
} }
const Home = ({ servers, history }: HomeProps) => { export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate();
const serversList = values(servers); const serversList = values(servers);
const hasServers = !isEmpty(serversList); const hasServers = !isEmpty(serversList);
useEffect(() => { useEffect(() => {
// Try to redirect to the first server marked as auto-connect // Try to redirect to the first server marked as auto-connect
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect); const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
autoConnectServer && history.push(`/server/${autoConnectServer.id}`);
}, []); }, []);
return ( return (
<div className="home"> <div className="home">
<Card className="home__main-card"> <Card className="home__main-card">
<Row noGutters> <Row className="g-0">
<div className="col-md-5 d-none d-md-block"> <div className="col-md-5 d-none d-md-block">
<div className="home__logo-wrapper"> <div className="home__logo-wrapper">
<div className="home__logo"> <div className="home__logo">
@@ -45,14 +45,14 @@ const Home = ({ servers, history }: HomeProps) => {
<div className="p-4 text-center"> <div className="p-4 text-center">
<p className="mb-5">This application will help you manage your Shlink servers.</p> <p className="mb-5">This application will help you manage your Shlink servers.</p>
<p> <p>
<Link to="/server/create" className="btn btn-outline-primary btn-lg mr-2"> <Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
<FontAwesomeIcon icon={faPlus} /> <span className="ml-1">Add a server</span> <FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span>
</Link> </Link>
</p> </p>
<p className="mb-0 mt-5"> <p className="mb-0 mt-5">
<ExternalLink href="https://shlink.io/documentation"> <ExternalLink href="https://shlink.io/documentation">
<small> <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> </small>
</ExternalLink> </ExternalLink>
</p> </p>
@@ -65,5 +65,3 @@ const Home = ({ servers, history }: HomeProps) => {
</div> </div>
); );
}; };
export default Home;

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import { FC } from 'react'; import { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss'; import './NoMenuLayout.scss';
export const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>; 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 { Link } from 'react-router-dom';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
interface NotFoundProps { type NotFoundProps = PropsWithChildren<{ to?: string }>;
to?: string;
}
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => ( export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<div className="home"> <div className="home">
<SimpleCard className="p-4"> <SimpleCard className="p-4">
<h2>Oops! We could not find requested route.</h2> <h2>Oops! We could not find requested route.</h2>
@@ -19,5 +17,3 @@ const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
</SimpleCard> </SimpleCard>
</div> </div>
); );
export default NotFound;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
position: relative; position: relative;
padding: 5px 0 0 6px; padding: 5px 0 0 6px;
border-radius: .3rem; border-radius: .3rem;
background-color: var(--input-color); background-color: var(--primary-color);
border: 1px solid var(--input-border-color); border: 1px solid var(--input-border-color);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
@@ -16,6 +16,16 @@
cursor: text; 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 { .react-tags.is-focused {
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%); box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
} }
@@ -76,7 +86,7 @@
font-size: 1.25rem; font-size: 1.25rem;
line-height: inherit; line-height: inherit;
color: var(--input-text-color); color: var(--input-text-color);
background-color: var(--input-color); background-color: inherit;
/* prevent autoresize overflowing the container */ /* prevent autoresize overflowing the container */
max-width: 100%; max-width: 100%;
@@ -88,6 +98,10 @@
outline: none; outline: none;
} }
.react-tags__search-input::placeholder {
color: #6c757d;
}
.react-tags__search-input::-ms-clear { .react-tags__search-input::-ms-clear {
display: none; display: none;
} }
@@ -113,7 +127,7 @@
background: var(--primary-color); background: var(--primary-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: .25rem; 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 { .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,35 +1,35 @@
import axios from 'axios'; import axios from 'axios';
import Bottle, { Decorator } from 'bottlejs'; import Bottle from 'bottlejs';
import ScrollToTop from '../ScrollToTop'; import { ScrollToTop } from '../ScrollToTop';
import MainHeader from '../MainHeader'; import { MainHeader } from '../MainHeader';
import Home from '../Home'; import { Home } from '../Home';
import MenuLayout from '../MenuLayout'; import { MenuLayout } from '../MenuLayout';
import AsideMenu from '../AsideMenu'; import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler'; import { ErrorHandler } from '../ErrorHandler';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer'; import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ImageDownloader } from './ImageDownloader'; import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services // Services
bottle.constant('window', (global as any).window); bottle.constant('window', (global as any).window);
bottle.constant('console', global.console); bottle.constant('console', global.console);
bottle.constant('axios', axios); bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window'); bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
// Components // Components
bottle.serviceFactory('ScrollToTop', ScrollToTop); bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown'); bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
bottle.decorator('MainHeader', withRouter);
bottle.serviceFactory('Home', () => Home); bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer); bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', withRouter); bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory( bottle.serviceFactory(
'MenuLayout', 'MenuLayout',
@@ -40,21 +40,26 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'CreateShortUrl', 'CreateShortUrl',
'ShortUrlVisits', 'ShortUrlVisits',
'TagVisits', 'TagVisits',
'DomainVisits',
'OrphanVisits', 'OrphanVisits',
'NonOrphanVisits',
'ServerError', 'ServerError',
'Overview', 'Overview',
'EditShortUrl', 'EditShortUrl',
'ManageDomains', 'ManageDomains',
); );
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton'); bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer); bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ])); bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer', 'sidebar']));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console'); bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
// Actions
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
}; };
export default provideServices; export default provideServices;

View File

@@ -1,5 +1,4 @@
import Bottle, { IContainer } from 'bottlejs'; import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux'; import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda'; import { pick } from 'ramda';
import provideApiServices from '../api/services/provideServices'; import provideApiServices from '../api/services/provideServices';
@@ -21,8 +20,8 @@ const bottle = new Bottle();
export const { container } = bottle; export const { container } = bottle;
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) => const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
(...args: any[]) => (container[serviceName] as T)(...args) as K; (...args: any[]) => (cont[serviceName] as T)(...args) as K;
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({ const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
...map, ...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called // Wrap actual action service in a function so that it is lazily created the first time it is called
@@ -34,11 +33,11 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
actionServiceNames.reduce(mapActionService, {}), actionServiceNames.reduce(mapActionService, {}),
); );
provideAppServices(bottle, connect, withRouter); provideAppServices(bottle, connect);
provideCommonServices(bottle, connect, withRouter); provideCommonServices(bottle, connect);
provideApiServices(bottle); provideApiServices(bottle);
provideShortUrlsServices(bottle, connect, withRouter); provideShortUrlsServices(bottle, connect);
provideServersServices(bottle, connect, withRouter); provideServersServices(bottle, connect);
provideTagsServices(bottle, connect); provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect); provideVisitsServices(bottle, connect);
provideUtilsServices(bottle); provideUtilsServices(bottle);

View File

@@ -5,11 +5,12 @@ import reducers from '../reducers';
import { migrateDeprecatedSettings } from '../settings/helpers'; import { migrateDeprecatedSettings } from '../settings/helpers';
import { ShlinkState } from './types'; 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 composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const localStorageConfig: RLSOptions = { const localStorageConfig: RLSOptions = {
states: [ 'settings', 'servers' ], states: ['settings', 'servers'],
namespace: 'shlink', namespace: 'shlink',
namespaceSeparator: '.', namespaceSeparator: '.',
debounce: 300, debounce: 300,

View File

@@ -14,6 +14,8 @@ import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList'; import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { VisitsInfo } from '../visits/types'; import { VisitsInfo } from '../visits/types';
import { Sidebar } from '../common/reducers/sidebar';
import { DomainVisits } from '../visits/reducers/domainVisits';
export interface ShlinkState { export interface ShlinkState {
servers: ServersMap; servers: ServersMap;
@@ -24,7 +26,9 @@ export interface ShlinkState {
shortUrlEdition: ShortUrlEdition; shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits; shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits; tagVisits: TagVisits;
domainVisits: DomainVisits;
orphanVisits: VisitsInfo; orphanVisits: VisitsInfo;
nonOrphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
tagsList: TagsList; tagsList: TagsList;
tagDelete: TagDeletion; tagDelete: TagDeletion;
@@ -34,6 +38,7 @@ export interface ShlinkState {
domainsList: DomainsList; domainsList: DomainsList;
visitsOverview: VisitsOverview; visitsOverview: VisitsOverview;
appUpdated: boolean; appUpdated: boolean;
sidebar: Sidebar;
} }
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any; export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

View File

@@ -1,19 +1,13 @@
import { FC, useEffect } from 'react'; import { FC, useEffect } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
faBan as forbiddenIcon,
faDotCircle as defaultDomainIcon,
faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomainRedirects } from '../api/types'; import { ShlinkDomainRedirects } from '../api/types';
import { useToggle } from '../utils/helpers/hooks';
import { OptionalString } from '../utils/utils'; import { OptionalString } from '../utils/utils';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
import { Domain } from './data'; import { Domain } from './data';
import { DomainStatusIcon } from './helpers/DomainStatusIcon'; import { DomainStatusIcon } from './helpers/DomainStatusIcon';
import { DomainDropdown } from './helpers/DomainDropdown';
interface DomainRowProps { interface DomainRowProps {
domain: Domain; domain: Domain;
@@ -39,9 +33,7 @@ const DefaultDomain: FC = () => (
export const DomainRow: FC<DomainRowProps> = ( export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, { domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
) => { ) => {
const [ isOpen, toggle ] = useToggle();
const { domain: authority, isDefault, redirects, status } = domain; const { domain: authority, isDefault, redirects, status } = domain;
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
useEffect(() => { useEffect(() => {
checkDomainHealth(domain.domain); checkDomainHealth(domain.domain);
@@ -63,26 +55,9 @@ export const DomainRow: FC<DomainRowProps> = (
<td className="responsive-table__cell text-lg-center" data-th="Status"> <td className="responsive-table__cell text-lg-center" data-th="Status">
<DomainStatusIcon status={status} /> <DomainStatusIcon status={status} />
</td> </td>
<td className="responsive-table__cell text-right"> <td className="responsive-table__cell text-end">
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}> <DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
</Button>
</span>
{!canEditDomain && (
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
Redirects for default domain cannot be edited here.
<br />
Use config options or env vars directly on the server.
</UncontrolledTooltip>
)}
</td> </td>
<EditDomainRedirectsModal
domain={domain}
isOpen={isOpen}
toggle={toggle}
editDomainRedirects={editDomainRedirects}
/>
</tr> </tr>
); );
}; };

View File

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

View File

@@ -3,7 +3,7 @@ import Message from '../utils/Message';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField'; import { SearchField } from '../utils/SearchField';
import { ShlinkDomainRedirects } from '../api/types'; import { ShlinkDomainRedirects } from '../api/types';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { DomainsList } from './reducers/domainsList'; import { DomainsList } from './reducers/domainsList';
@@ -18,7 +18,7 @@ interface ManageDomainsProps {
selectedServer: SelectedServer; 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> = ( export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer }, { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
@@ -45,7 +45,7 @@ export const ManageDomains: FC<ManageDomainsProps> = (
return ( return (
<SimpleCard> <SimpleCard>
<table className="table table-hover mb-0"> <table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header"> <thead className="responsive-table__header">
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr> <tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
</thead> </thead>

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

@@ -18,7 +18,7 @@ interface DomainStatusIconProps {
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => { export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
const ref = useRef<HTMLSpanElement>(); const ref = useRef<HTMLSpanElement>();
const matchesMobile = () => matchMedia('(max-width: 991px)').matches; const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile()); const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
useEffect(() => { useEffect(() => {
const listener = () => setIsMobile(matchesMobile()); const listener = () => setIsMobile(matchesMobile());

View File

@@ -1,7 +1,7 @@
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; 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 { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import { InfoTooltip } from '../../utils/InfoTooltip'; import { InfoTooltip } from '../../utils/InfoTooltip';
@@ -12,8 +12,8 @@ interface EditDomainRedirectsModalProps {
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
} }
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => ( const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
<FormGroupContainer <InputFormGroup
{...rest} {...rest}
required={false} required={false}
type="url" type="url"
@@ -25,9 +25,9 @@ const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast,
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = ( export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
{ isOpen, toggle, domain, editDomainRedirects }, { isOpen, toggle, domain, editDomainRedirects },
) => { ) => {
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? ''); const [baseUrlRedirect, setBaseUrlRedirect] = useState(domain.redirects?.baseUrlRedirect ?? '');
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? ''); const [regular404Redirect, setRegular404Redirect] = useState(domain.redirects?.regular404Redirect ?? '');
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState( const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
domain.redirects?.invalidShortUrlRedirect ?? '', domain.redirects?.invalidShortUrlRedirect ?? '',
); );
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, { 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> <ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
<ModalBody> <ModalBody>
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}> <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. Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
</InfoTooltip> </InfoTooltip>
Base URL Base URL
</FormGroup> </FormGroup>
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}> <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>, Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
will be redirected to this URL. will be redirected to this URL.
</InfoTooltip> </InfoTooltip>
Regular 404 Regular 404
</FormGroup> </FormGroup>
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}> <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 Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
redirected to this URL. redirected to this URL.
</InfoTooltip> </InfoTooltip>

View File

@@ -5,11 +5,9 @@ import { GetState } from '../../container/types';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { parseApiError } from '../../api/utils'; 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_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR'; export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
/* eslint-enable padding-line-between-statements */
export interface EditDomainRedirectsAction extends Action<string> { export interface EditDomainRedirectsAction extends Action<string> {
domain: string; domain: string;
@@ -21,10 +19,10 @@ export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder
domainRedirects: Partial<ShlinkDomainRedirects>, domainRedirects: Partial<ShlinkDomainRedirects>,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START }); dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
const { editDomainRedirects } = buildShlinkApiClient(getState); const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
try { try {
const redirects = await editDomainRedirects({ domain, ...domainRedirects }); const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
} catch (e: any) { } catch (e: any) {

View File

@@ -10,13 +10,11 @@ import { hasServerData } from '../../servers/data';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; 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_START = 'shlink/domainsList/LIST_DOMAINS_START';
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
/* eslint-enable padding-line-between-statements */
export interface DomainsList { export interface DomainsList {
domains: Domain[]; domains: Domain[];
@@ -55,10 +53,10 @@ export type DomainsCombinedAction = ListDomainsAction
& ValidateDomain; & ValidateDomain;
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects }; (d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status }; (d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
export default buildReducer<DomainsList, DomainsCombinedAction>({ export default buildReducer<DomainsList, DomainsCombinedAction>({
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
@@ -86,15 +84,15 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
getState: GetState, getState: GetState,
) => { ) => {
dispatch({ type: LIST_DOMAINS_START }); dispatch({ type: LIST_DOMAINS_START });
const { listDomains } = buildShlinkApiClient(getState); const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
try { try {
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({ const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
defaultRedirects, defaultRedirects,
})); }));
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects }); dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
} catch (e: any) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
} }

View File

@@ -8,12 +8,12 @@ import { editDomainRedirects } from '../reducers/domainRedirects';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('DomainSelector', () => DomainSelector); bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
bottle.serviceFactory('ManageDomains', () => ManageDomains); bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect( bottle.decorator('ManageDomains', connect(
[ 'domainsList', 'selectedServer' ], ['domainsList', 'selectedServer'],
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ], ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
)); ));
// Actions // Actions

View File

@@ -11,6 +11,10 @@
outline: none !important; outline: none !important;
} }
:root {
scroll-behavior: auto;
}
html, html,
body, body,
#root { #root {
@@ -19,6 +23,17 @@ body,
color: var(--text-color); 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 { .bg-main {
background-color: $mainColor !important; background-color: $mainColor !important;
} }
@@ -34,7 +49,7 @@ body,
} }
.card { .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); background-color: var(--primary-color);
border-color: var(--border-color); border-color: var(--border-color);
} }
@@ -74,7 +89,8 @@ hr {
border-color: var(--table-border-color); border-color: var(--table-border-color);
} }
.page-link:hover { .page-link:hover,
.page-link:focus {
background-color: var(--secondary-color); 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,
.dropdown-item-text { .dropdown-item-text {
color: var(--text-color); color: var(--text-color);
@@ -133,10 +165,15 @@ hr {
.close, .close,
.close:hover, .close:hover,
.table, .table,
.table-hover tbody tr:hover { .table-hover > tbody > tr:hover > *,
.table-hover > tbody > tr > * {
color: var(--text-color); color: var(--text-color);
} }
.btn-close {
filter: var(--btn-close-filter);
}
.table-hover tbody tr:hover { .table-hover tbody tr:hover {
background-color: var(--secondary-color); 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 { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { homepage } from '../package.json'; import pack from '../package.json';
import { container } from './container'; import { container } from './container';
import { store } from './container/store'; import { store } from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet'; import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; 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 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import './index.scss'; import './index.scss';
@@ -15,9 +16,9 @@ fixLeafletIcons();
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container; const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
render( createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
<Provider store={store}> <Provider store={store}>
<BrowserRouter basename={homepage}> <BrowserRouter basename={pack.homepage}>
<ErrorHandler> <ErrorHandler>
<ScrollToTop> <ScrollToTop>
<App /> <App />
@@ -25,14 +26,11 @@ render(
</ErrorHandler> </ErrorHandler>
</BrowserRouter> </BrowserRouter>
</Provider>, </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 // Learn more about service workers: https://cra.link/PWA
registerServiceWorker({ registerServiceWorker({
onUpdate() { 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 { FC, useEffect } from 'react';
import { pipe } from 'ramda'; import { pipe } from 'ramda';
import { useParams } from 'react-router-dom';
import { CreateVisit } from '../../visits/types'; import { CreateVisit } from '../../visits/types';
import { MercureInfo } from '../reducers/mercureInfo'; import { MercureInfo } from '../reducers/mercureInfo';
import { bindToMercureTopic } from './index'; import { bindToMercureTopic } from './index';
@@ -12,29 +13,31 @@ export interface MercureBoundProps {
export function boundToMercureHub<T = {}>( export function boundToMercureHub<T = {}>(
WrappedComponent: FC<MercureBoundProps & T>, WrappedComponent: FC<MercureBoundProps & T>,
getTopicsForProps: (props: T) => string[], getTopicsForProps: (props: T, routeParams: any) => string[],
) { ) {
const pendingUpdates = new Set<CreateVisit>(); const pendingUpdates = new Set<CreateVisit>();
return (props: MercureBoundProps & T) => { return (props: MercureBoundProps & T) => {
const { createNewVisits, loadMercureInfo, mercureInfo } = props; const { createNewVisits, loadMercureInfo, mercureInfo } = props;
const { interval } = mercureInfo; const { interval } = mercureInfo;
const params = useParams();
useEffect(() => { useEffect(() => {
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]); const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicsForProps(props), onMessage, loadMercureInfo); const topics = getTopicsForProps(props, params);
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
if (!interval) { if (!interval) {
return closeEventSource; return closeEventSource;
} }
const timer = setInterval(() => { const timer = setInterval(() => {
createNewVisits([ ...pendingUpdates ]); createNewVisits([...pendingUpdates]);
pendingUpdates.clear(); pendingUpdates.clear();
}, interval * 1000 * 60); }, interval * 1000 * 60);
return pipe(() => clearInterval(timer), () => closeEventSource?.()); return pipe(() => clearInterval(timer), () => closeEventSource?.());
}, [ mercureInfo ]); }, [mercureInfo]);
return <WrappedComponent {...props} />; return <WrappedComponent {...props} />;
}; };

View File

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

View File

@@ -7,7 +7,9 @@ import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits';
import domainVisitsReducer from '../visits/reducers/domainVisits';
import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits';
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail'; import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList'; import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete'; import tagDeleteReducer from '../tags/reducers/tagDelete';
@@ -17,6 +19,7 @@ import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList'; import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import appUpdatesReducer from '../app/reducers/appUpdates'; import appUpdatesReducer from '../app/reducers/appUpdates';
import sidebarReducer from '../common/reducers/sidebar';
import { ShlinkState } from '../container/types'; import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({ export default combineReducers<ShlinkState>({
@@ -28,7 +31,9 @@ export default combineReducers<ShlinkState>({
shortUrlEdition: shortUrlEditionReducer, shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer, shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer, tagVisits: tagVisitsReducer,
domainVisits: domainVisitsReducer,
orphanVisits: orphanVisitsReducer, orphanVisits: orphanVisitsReducer,
nonOrphanVisits: nonOrphanVisitsReducer,
shortUrlDetail: shortUrlDetailReducer, shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer, tagsList: tagsListReducer,
tagDelete: tagDeleteReducer, tagDelete: tagDeleteReducer,
@@ -38,4 +43,5 @@ export default combineReducers<ShlinkState>({
domainsList: domainsListReducer, domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer, visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer, appUpdated: appUpdatesReducer,
sidebar: sidebarReducer,
}); });

View File

@@ -1,10 +1,10 @@
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { RouterProps } from 'react-router';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import { useNavigate } from 'react-router-dom';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks'; import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServersMap, ServerWithId } from './data'; import { ServerData, ServersMap, ServerWithId } from './data';
@@ -12,7 +12,7 @@ import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps extends RouterProps { interface CreateServerProps {
createServer: (server: ServerWithId) => void; createServer: (server: ServerWithId) => void;
servers: ServersMap; servers: ServersMap;
} }
@@ -27,13 +27,15 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
); );
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => ( const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
{ servers, createServer, history: { push, goBack } }: CreateServerProps, { servers, createServer }: CreateServerProps,
) => { ) => {
const navigate = useNavigate();
const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length; const hasServers = !!Object.keys(servers).length;
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [serversImported, setServersImported] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle(); const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [ serverData, setServerData ] = useState<ServerData | undefined>(); const [serverData, setServerData] = useState<ServerData | undefined>();
const save = () => { const save = () => {
if (!serverData) { if (!serverData) {
return; return;
@@ -42,7 +44,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
const id = uuid(); const id = uuid();
createServer({ ...serverData, id }); createServer({ ...serverData, id });
push(`/server/${id}`); navigate(`/server/${id}`);
}; };
useEffect(() => { useEffect(() => {
@@ -51,15 +53,16 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
); );
serverExists ? toggleConfirmModal() : save(); serverExists ? toggleConfirmModal() : save();
}, [ serverData ]); }, [serverData]);
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}> <ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
{!hasServers && {!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />} <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
)}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>} {hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ml-2">Create server</Button> <Button outline color="primary" className="ms-2">Create server</Button>
</ServerForm> </ServerForm>
{serversImported && <ImportResult type="success" />} {serversImported && <ImportResult type="success" />}
@@ -67,7 +70,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
<DuplicatedServersModal <DuplicatedServersModal
isOpen={isConfirmModalOpen} isOpen={isConfirmModalOpen}
duplicatedServers={serverData ? [ serverData ] : []} duplicatedServers={serverData ? [serverData] : []}
onDiscard={goBack} onDiscard={goBack}
onSave={save} onSave={save}
/> />

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

View File

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

View File

@@ -1,6 +1,7 @@
import { FC } from 'react'; import { FC } from 'react';
import { Button } from 'reactstrap'; 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 { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data'; import { isServerWithId, ServerData } from './data';
@@ -9,9 +10,9 @@ interface EditServerProps {
editServer: (serverId: string, serverData: ServerData) => void; editServer: (serverId: string, serverData: ServerData) => void;
} }
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(( export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
{ editServer, selectedServer, history: { goBack } }, const goBack = useGoBack();
) => {
if (!isServerWithId(selectedServer)) { if (!isServerWithId(selectedServer)) {
return null; return null;
} }
@@ -28,7 +29,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
initialValues={selectedServer} initialValues={selectedServer}
onSubmit={handleSubmit} 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> <Button outline color="primary">Save</Button>
</ServerForm> </ServerForm>
</NoMenuLayout> </NoMenuLayout>

View File

@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField'; import { SearchField } from '../utils/SearchField';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { StateFlagTimeout } from '../utils/helpers/hooks'; import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ImportServersBtnProps } from './helpers/ImportServersBtn';
@@ -26,16 +26,16 @@ export const ManageServers = (
ManageServersRow: FC<ManageServersRowProps>, ManageServersRow: FC<ManageServersRowProps>,
): FC<ManageServersProps> => ({ servers }) => { ): FC<ManageServersProps> => ({ servers }) => {
const allServers = Object.values(servers); const allServers = Object.values(servers);
const [ serversList, setServersList ] = useState(allServers); const [serversList, setServersList] = useState(allServers);
const filterServers = (searchTerm: string) => setServersList( const filterServers = (searchTerm: string) => setServersList(
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)), allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
); );
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect); const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
useEffect(() => { useEffect(() => {
setServersList(Object.values(servers)); setServersList(Object.values(servers));
}, [ servers ]); }, [servers]);
return ( return (
<NoMenuLayout> <NoMenuLayout>
@@ -45,12 +45,12 @@ export const ManageServers = (
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0"> <div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn> <ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{allServers.length > 0 && ( {allServers.length > 0 && (
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}> <Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers <FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button> </Button>
)} )}
</div> </div>
<div className="col-md-6 text-md-right d-flex d-md-block"> <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"> <Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server <FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button> </Button>
@@ -58,20 +58,20 @@ export const ManageServers = (
</Row> </Row>
<SimpleCard> <SimpleCard>
<table className="table table-hover mb-0"> <table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header"> <thead className="responsive-table__header">
<tr> <tr>
{hasAutoConnect && <th style={{ width: '50px' }} />} {hasAutoConnect && <th aria-label="Auto-connect" style={{ width: '50px' }} />}
<th>Name</th> <th>Name</th>
<th>Base URL</th> <th>Base URL</th>
<th /> <th aria-label="Options" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>} {!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
{serversList.map((server) => {serversList.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />) <ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
} ))}
</tbody> </tbody>
</table> </table>
</SimpleCard> </SimpleCard>

View File

@@ -31,7 +31,7 @@ export const ManageServersRow = (
<Link to={`/server/${server.id}`}>{server.name}</Link> <Link to={`/server/${server.id}`}>{server.name}</Link>
</th> </th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td> <td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-right"> <td className="responsive-table__cell text-end">
<ManageServersRowDropdown server={server} /> <ManageServersRowDropdown server={server} />
</td> </td>
</tr> </tr>

View File

@@ -25,8 +25,8 @@ interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownP
export const ManageServersRowDropdown = ( export const ManageServersRowDropdown = (
DeleteServerModal: FC<DeleteServerModalProps>, DeleteServerModal: FC<DeleteServerModalProps>,
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => { ): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
const [ isMenuOpen, toggleMenu ] = useToggle(); const [isMenuOpen, toggleMenu] = useToggle();
const [ isModalOpen,, showModal, hideModal ] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const serverUrl = `/server/${server.id}`; const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server; const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon; const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;

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,6 +1,6 @@
import { FC, useEffect } from 'react'; import { FC, useEffect } from 'react';
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap'; import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import { Link, useHistory } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList'; import { TagsList } from '../tags/reducers/tagsList';
@@ -8,11 +8,11 @@ import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Versions } from '../utils/helpers/version';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { ShlinkShortUrlsListParams } from '../api/types'; import { ShlinkShortUrlsListParams } from '../api/types';
import { supportsNonOrphanVisits } from '../utils/helpers/features';
import { getServerId, SelectedServer } from './data'; import { getServerId, SelectedServer } from './data';
import './Overview.scss'; import { HighlightCard } from './helpers/HighlightCard';
interface OverviewConnectProps { interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
@@ -27,7 +27,6 @@ interface OverviewConnectProps {
export const Overview = ( export const Overview = (
ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsTable: FC<ShortUrlsTableProps>,
CreateShortUrl: FC<CreateShortUrlProps>, CreateShortUrl: FC<CreateShortUrlProps>,
ForServerVersion: FC<Versions>,
) => boundToMercureHub(({ ) => boundToMercureHub(({
shortUrlsList, shortUrlsList,
listShortUrls, listShortUrls,
@@ -41,7 +40,8 @@ export const Overview = (
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview; const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const history = useHistory(); const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } }); listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
@@ -52,45 +52,33 @@ export const Overview = (
return ( return (
<> <>
<Row> <Row>
<div className="col-md-6 col-xl-3"> <div className="col-lg-6 col-xl-3 mb-3">
<Card className="overview__card mb-3" body> <HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle> {loadingVisits ? 'Loading...' : prettify(visitsCount)}
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText> </HighlightCard>
</Card>
</div> </div>
<div className="col-md-6 col-xl-3"> <div className="col-lg-6 col-xl-3 mb-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/orphan-visits`}> <HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}>
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle> {loadingVisits ? 'Loading...' : prettify(orphanVisitsCount)}
<CardText tag="h2"> </HighlightCard>
<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> </div>
<div className="col-md-6 col-xl-3"> <div className="col-lg-6 col-xl-3 mb-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}> <HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle> {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
<CardText tag="h2"> </HighlightCard>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</CardText>
</Card>
</div> </div>
<div className="col-md-6 col-xl-3"> <div className="col-lg-6 col-xl-3 mb-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/manage-tags`}> <HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle> {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText> </HighlightCard>
</Card>
</div> </div>
</Row> </Row>
<Card className="mb-3"> <Card className="mb-3">
<CardHeader> <CardHeader>
<span className="d-sm-none">Create a short URL</span> <span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5> <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> </CardHeader>
<CardBody> <CardBody>
<CreateShortUrl basicMode /> <CreateShortUrl basicMode />
@@ -100,17 +88,17 @@ export const Overview = (
<CardHeader> <CardHeader>
<span className="d-sm-none">Recently created URLs</span> <span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5> <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> </CardHeader>
<CardBody> <CardBody>
<ShortUrlsTable <ShortUrlsTable
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
selectedServer={selectedServer} selectedServer={selectedServer}
className="mb-0" className="mb-0"
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)} onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/> />
</CardBody> </CardBody>
</Card> </Card>
</> </>
); );
}, () => [ Topics.visits, Topics.orphanVisits ]); }, () => [Topics.visits, Topics.orphanVisits]);

View File

@@ -17,7 +17,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
if (isEmpty(serversList)) { if (isEmpty(serversList)) {
return ( return (
<DropdownItem tag={Link} to="/server/create"> <DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span> <FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
</DropdownItem> </DropdownItem>
); );
} }
@@ -31,7 +31,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
))} ))}
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem tag={Link} to="/manage-servers"> <DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span> <FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
</DropdownItem> </DropdownItem>
</> </>
); );
@@ -40,9 +40,9 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
return ( return (
<UncontrolledDropdown nav inNavbar> <UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret> <DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span> <FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
</DropdownToggle> </DropdownToggle>
<DropdownMenu right>{renderServers()}</DropdownMenu> <DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
</UncontrolledDropdown> </UncontrolledDropdown>
); );
}; };

View File

@@ -8,7 +8,7 @@
.servers-list__list-group:not(.servers-list__list-group--embedded) { .servers-list__list-group:not(.servers-list__list-group--embedded) {
max-width: 400px; 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 { .servers-list__server-item.servers-list__server-item {
@@ -17,6 +17,10 @@
padding: .75rem 2.5rem .75rem 1rem; padding: .75rem 2.5rem .75rem 1rem;
} }
.servers-list__server-item:not(:hover) {
color: $mainColor;
}
.servers-list__server-item:hover { .servers-list__server-item:hover {
background-color: var(--secondary-color); 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 { ListGroup, ListGroupItem } from 'reactstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -7,10 +7,10 @@ import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons
import { ServerWithId } from './data'; import { ServerWithId } from './data';
import './ServersListGroup.scss'; import './ServersListGroup.scss';
interface ServersListGroupProps { type ServersListGroupProps = PropsWithChildren<{
servers: ServerWithId[]; servers: ServerWithId[];
embedded?: boolean; embedded?: boolean;
} }>;
const ServerListItem = ({ id, name }: { id: string; name: string }) => ( const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item"> <ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">

View File

@@ -35,15 +35,15 @@ export const hasServerData = (server: SelectedServer | ServerData): server is Se
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey; !!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId => export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
!!server?.hasOwnProperty('id'); !!(server as ServerWithId)?.id;
export const isReachableServer = (server: SelectedServer): server is ReachableServer => export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
!!server?.hasOwnProperty('version'); !!(server as ReachableServer)?.version;
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer => 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 => export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
omit<ServerWithId, 'id' | 'autoConnect'>([ 'id', 'autoConnect' ], server); omit<ServerWithId, 'id' | 'autoConnect'>(['id', 'autoConnect'], server);

View File

@@ -20,12 +20,12 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
<ModalBody> <ModalBody>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p> <p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul> <ul>
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? ( {duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}> <Fragment key={index}>
<li>URL: <b>{url}</b></li> <li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li> <li>API key: <b>{apiKey}</b></li>
</Fragment> </Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)} ) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
</ul> </ul>
<span> <span>
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}? {hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?

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

@@ -1,4 +1,4 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react'; import { useRef, RefObject, ChangeEvent, MutableRefObject, useState, useEffect, FC, PropsWithChildren } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { complement, pipe } from 'ramda'; import { complement, pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
@@ -11,12 +11,12 @@ import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>; type Ref<T> = RefObject<T> | MutableRefObject<T>;
export interface ImportServersBtnProps { export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void; onImport?: () => void;
onImportError?: (error: Error) => void; onImportError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom'; tooltipPlacement?: 'top' | 'bottom';
className?: string; className?: string;
} }>;
interface ImportServersBtnConnectProps extends ImportServersBtnProps { interface ImportServersBtnConnectProps extends ImportServersBtnProps {
createServers: (servers: ServerData[]) => void; createServers: (servers: ServerData[]) => void;
@@ -38,9 +38,9 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
className = '', className = '',
}) => { }) => {
const ref = fileRef ?? useRef<HTMLInputElement>(); const ref = fileRef ?? useRef<HTMLInputElement>();
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>(); const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>();
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [ isModalOpen,, showModal, hideModal ] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const create = pipe(createServers, onImport); const create = pipe(createServers, onImport);
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal); const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
const createNonDuplicatedServers = pipe( const createNonDuplicatedServers = pipe(
@@ -52,7 +52,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
.then(setServersToCreate) .then(setServersToCreate)
.then(() => { .then(() => {
// Reset input after processing file // 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); .catch(onImportError);
@@ -62,12 +62,12 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
} }
const existingServers = Object.values(servers); const existingServers = Object.values(servers);
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers)); const dupServers = serversToCreate.filter(serversFiltering(existingServers));
const hasDuplicatedServers = !!duplicatedServers.length; const hasDuplicatedServers = !!dupServers.length;
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers); !hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(dupServers);
hasDuplicatedServers && showModal(); hasDuplicatedServers && showModal();
}, [ serversToCreate ]); }, [serversToCreate]);
return ( return (
<> <>

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,9 @@ interface WithoutSelectedServerProps {
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) { export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
return (props: WithoutSelectedServerProps & T) => { return (props: WithoutSelectedServerProps & T) => {
const { resetSelectedServer } = props;
useEffect(() => { useEffect(() => {
props.resetSelectedServer(); resetSelectedServer();
}, []); }, []);
return <WrappedComponent {...props} />; return <WrappedComponent {...props} />;

View File

@@ -1,17 +1,17 @@
import { pipe, prop } from 'ramda'; import { pipe, prop } from 'ramda';
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { homepage } from '../../../package.json'; import pack from '../../../package.json';
import { hasServerData, ServerData } from '../data'; import { hasServerData, ServerData } from '../data';
import { createServers } from './servers'; import { createServers } from './servers';
const responseToServersList = pipe( const responseToServersList = pipe(
prop<any, any>('data'), prop<any, any>('data'),
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [], (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []),
); );
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => { export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
const resp = await get(`${homepage}/servers.json`); const resp = await get(`${pack.homepage}/servers.json`);
const remoteList = responseToServersList(resp); const remoteList = responseToServersList(resp);
dispatch(createServers(remoteList)); dispatch(createServers(remoteList));

View File

@@ -7,21 +7,19 @@ import { ShlinkHealth } from '../../api/types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER'; export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
export const MIN_FALLBACK_VERSION = '1.0.0'; export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999'; export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest'; export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
export interface SelectServerAction extends Action<string> { export interface SelectServerAction extends Action<string> {
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
const versionToSemVer = pipe( const versionToSemVer = pipe(
(version: string) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version, (version: string) => (version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version),
toSemVer(MIN_FALLBACK_VERSION), toSemVer(MIN_FALLBACK_VERSION),
); );

View File

@@ -4,12 +4,10 @@ import { Action } from 'redux';
import { ServerData, ServersMap, ServerWithId } from '../data'; import { ServerData, ServersMap, ServerWithId } from '../data';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER'; export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER'; export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS'; export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT'; export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
/* eslint-enable padding-line-between-statements */
export interface CreateServersAction extends Action<string> { export interface CreateServersAction extends Action<string> {
newServers: ServersMap; newServers: ServersMap;
@@ -37,9 +35,9 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({ export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }), [CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state), [DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId] [EDIT_SERVER]: (state, { serverId, serverData }: any) => (
? state !state[serverId] ? state : assoc(serverId, { ...state[serverId], ...serverData }, state)
: assoc(serverId, { ...state[serverId], ...serverData }, state), ),
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => { [SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
if (!state[serverId]) { if (!state[serverId]) {
return state; return state;
@@ -50,7 +48,7 @@ export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction
} }
return fromPairs( return fromPairs(
toPairs(state).map(([ evaluatedServerId, server ]) => [ toPairs(state).map(([evaluatedServerId, server]) => [
evaluatedServerId, evaluatedServerId,
{ ...server, autoConnect: evaluatedServerId === serverId }, { ...server, autoConnect: evaluatedServerId === serverId },
]), ]),
@@ -66,7 +64,7 @@ export const createServers = pipe(
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }), (newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
); );
export const createServer = (server: ServerWithId) => createServers([ server ]); export const createServer = (server: ServerWithId) => createServers([server]);
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({ export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
type: EDIT_SERVER, type: EDIT_SERVER,

View File

@@ -1,8 +1,8 @@
import { values } from 'ramda'; import { values } from 'ramda';
import { CsvJson } from 'csvjson';
import LocalStorage from '../../utils/services/LocalStorage'; import LocalStorage from '../../utils/services/LocalStorage';
import { ServersMap, serverWithIdToServerData } from '../data'; import { ServersMap, serverWithIdToServerData } from '../data';
import { saveCsv } from '../../utils/helpers/files'; import { saveCsv } from '../../utils/helpers/files';
import { JsonToCsv } from '../../utils/helpers/csvjson';
const SERVERS_FILENAME = 'shlink-servers.csv'; const SERVERS_FILENAME = 'shlink-servers.csv';
@@ -10,14 +10,14 @@ export default class ServersExporter {
public constructor( public constructor(
private readonly storage: LocalStorage, private readonly storage: LocalStorage,
private readonly window: Window, private readonly window: Window,
private readonly csvjson: CsvJson, private readonly jsonToCsv: JsonToCsv,
) {} ) {}
public readonly exportServers = async () => { public readonly exportServers = async () => {
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData); const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
try { try {
const csv = this.csvjson.toCSV(servers, { headers: 'key' }); const csv = this.jsonToCsv(servers);
saveCsv(this.window, csv, SERVERS_FILENAME); saveCsv(this.window, csv, SERVERS_FILENAME);
} catch (e) { } catch (e) {

View File

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

View File

@@ -1,5 +1,4 @@
import csvjson from 'csvjson'; import Bottle from 'bottlejs';
import Bottle, { Decorator } from 'bottlejs';
import CreateServer from '../CreateServer'; import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown'; import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal'; import DeleteServerModal from '../DeleteServerModal';
@@ -9,7 +8,6 @@ import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer'; import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers'; import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers'; import { fetchServers } from '../reducers/remoteServers';
import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError'; import { ServerError } from '../helpers/ServerError';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
@@ -20,7 +18,7 @@ import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
import { ServersImporter } from './ServersImporter'; import { ServersImporter } from './ServersImporter';
import ServersExporter from './ServersExporter'; import ServersExporter from './ServersExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory( bottle.serviceFactory(
'ManageServers', 'ManageServers',
@@ -30,49 +28,45 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'useStateFlagTimeout', 'useStateFlagTimeout',
'ManageServersRow', 'ManageServersRow',
); );
bottle.decorator('ManageServers', connect([ 'servers' ])); bottle.decorator('ManageServers', withoutSelectedServer);
bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer']));
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown'); bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal'); bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ])); bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', withoutSelectedServer); bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ])); bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServer', 'resetSelectedServer']));
bottle.serviceFactory('EditServer', EditServer, 'ServerError'); bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ])); bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
bottle.serviceFactory('ServersDropdown', () => ServersDropdown); bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ])); bottle.decorator('ServersDropdown', connect(['servers', 'selectedServer']));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter); bottle.decorator('DeleteServerModal', connect(null, ['deleteServer']));
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal'); bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter'); bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ])); bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers']));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton'); bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ])); bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl', 'ForServerVersion'); bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
bottle.decorator('Overview', connect( bottle.decorator('Overview', connect(
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview' ], ['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'],
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview' ], ['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
)); ));
// Services // Services
bottle.constant('csvjson', csvjson);
bottle.constant('fileReaderFactory', () => new FileReader()); bottle.constant('fileReaderFactory', () => new FileReader());
bottle.service('ServersImporter', ServersImporter, 'csvjson', 'fileReaderFactory'); bottle.service('ServersImporter', ServersImporter, 'csvToJson', 'fileReaderFactory');
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson'); bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
// Actions // Actions
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo'); bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');

View File

@@ -1,5 +1,4 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized! // This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules // See https://developers.google.com/web/tools/workbox/modules

View File

@@ -2,6 +2,8 @@ import { FormGroup, Input } from 'reactstrap';
import classNames from 'classnames'; import classNames from 'classnames';
import ToggleSwitch from '../utils/ToggleSwitch'; import ToggleSwitch from '../utils/ToggleSwitch';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings } from './reducers/settings'; import { Settings } from './reducers/settings';
interface RealTimeUpdatesProps { interface RealTimeUpdatesProps {
@@ -10,7 +12,7 @@ interface RealTimeUpdatesProps {
setRealTimeUpdatesInterval: (interval: number) => void; setRealTimeUpdatesInterval: (interval: number) => void;
} }
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`; const intervalValue = (interval?: number) => (!interval ? '' : `${interval}`);
const RealTimeUpdatesSettings = ( const RealTimeUpdatesSettings = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps, { settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
@@ -19,15 +21,16 @@ const RealTimeUpdatesSettings = (
<FormGroup> <FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}> <ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates. Enable or disable real-time updates.
<small className="form-text text-muted"> <FormText>
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>. Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</small> </FormText>
</ToggleSwitch> </ToggleSwitch>
</FormGroup> </FormGroup>
<FormGroup className="mb-0"> <LabeledFormGroup
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}> noMargin
Real-time updates frequency (in minutes): label="Real-time updates frequency (in minutes):"
</label> labelClassName={classNames('form-label', { 'text-muted': !realTimeUpdates.enabled })}
>
<Input <Input
type="number" type="number"
min={0} min={0}
@@ -37,16 +40,16 @@ const RealTimeUpdatesSettings = (
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))} onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/> />
{realTimeUpdates.enabled && ( {realTimeUpdates.enabled && (
<small className="form-text text-muted"> <FormText>
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && ( {realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span> <span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}. Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span> </span>
)} )}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'} {!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small> </FormText>
)} )}
</FormGroup> </LabeledFormGroup>
</SimpleCard> </SimpleCard>
); );

View File

@@ -1,18 +1,11 @@
import { FC, ReactNode } from 'react'; import { FC, ReactNode } from 'react';
import { Row } from 'reactstrap'; import { Navigate, Routes, Route } from 'react-router-dom';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { NavPillItem, NavPills } from '../utils/NavPills';
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => ( const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
<> <>
{items.map((child, index) => ( {items.map((child, index) => <div key={index} className="mb-3">{child}</div>)}
<Row key={index}>
{child.map((subChild, subIndex) => (
<div key={subIndex} className={`col-lg-${12 / child.length} mb-3`}>
{subChild}
</div>
))}
</Row>
))}
</> </>
); );
@@ -25,13 +18,18 @@ const Settings = (
Tags: FC, Tags: FC,
) => () => ( ) => () => (
<NoMenuLayout> <NoMenuLayout>
<SettingsSections <NavPills className="mb-3">
items={[ <NavPillItem to="general">General</NavPillItem>
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key <NavPillItem to="short-urls">Short URLs</NavPillItem>
[ <ShortUrlCreation />, <ShortUrlsList /> ], // eslint-disable-line react/jsx-key <NavPillItem to="other-items">Other items</NavPillItem>
[ <Tags />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key </NavPills>
]}
/> <Routes>
<Route path="general" element={<SettingsSections items={[<UserInterface />, <RealTimeUpdates />]} />} />
<Route path="short-urls" element={<SettingsSections items={[<ShortUrlCreation />, <ShortUrlsList />]} />} />
<Route path="other-items" element={<SettingsSections items={[<Tags />, <Visits />]} />} />
<Route path="*" element={<Navigate replace to="general" />} />
</Routes>
</NoMenuLayout> </NoMenuLayout>
); );

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