Compare commits

..

202 Commits

Author SHA1 Message Date
Alejandro Celaya
3e58d861ec Merge pull request #608 from shlinkio/develop
Release 3.6.0
2022-03-17 20:41:27 +01:00
Alejandro Celaya
2d8c2f92c4 Added v3.6.0 to changelog 2022-03-17 20:38:38 +01:00
Alejandro Celaya
56fa114f3c Merge pull request #607 from acelaya-forks/feature/export-urls
Feature/export urls
2022-03-17 20:36:11 +01:00
Alejandro Celaya
0a57390c46 Created ExportShortUrlsBtn test 2022-03-17 20:28:47 +01:00
Alejandro Celaya
ea7345b872 Updated changelog 2022-03-13 19:09:06 +01:00
Alejandro Celaya
e44520b2c2 Enhanced ReportExporter test 2022-03-13 19:07:33 +01:00
Alejandro Celaya
92ddcad753 Implemented short URLs exporting 2022-03-13 18:56:42 +01:00
Alejandro Celaya
e632c5b04f Abstracted logic to parse tags from string to array and back for the query 2022-03-13 11:14:30 +01:00
Alejandro Celaya
47d30aaa34 Created ExportBtn test 2022-03-13 11:00:45 +01:00
Alejandro Celaya
a26019ca78 Re-positioned components in short urls list for consistency with other sections 2022-03-13 10:43:57 +01:00
Alejandro Celaya
ef8db5e2cd Moved short URL ordering dropdown to ShortUrlsFilteringBar to simplify positioning 2022-03-13 10:32:27 +01:00
Alejandro Celaya
18f952f4fc Merge branch 'develop' into feature/export-urls 2022-03-13 09:56:59 +01:00
Alejandro Celaya
389f4efa4d Merge pull request #602 from acelaya-forks/feature/font-awesome-6
Updated to fontawesome 6
2022-03-13 08:44:39 +01:00
Alejandro Celaya
d1e6b052d9 Updated to fontawesome 6 2022-03-13 08:40:52 +01:00
Alejandro Celaya
7fd360495b Created button to use when anything needs to be exported 2022-03-12 20:51:30 +01:00
Alejandro Celaya
187e26810d Merge pull request #600 from acelaya-forks/feature/update-react-datepicker
Updated to latest react-datepicker major version
2022-03-11 17:27:23 +01:00
Alejandro Celaya
8a1edfe7cf Fixed usage of old module in InfoTooltip test 2022-03-11 17:23:43 +01:00
Alejandro Celaya
81d405d7be Updated to latest react-datepicker major version 2022-03-11 17:16:24 +01:00
Alejandro Celaya
c4148f0494 Fixed reactstrap 9 deprecated warnings 2022-03-11 16:37:41 +01:00
Alejandro Celaya
a8f996bec7 Merge pull request #599 from acelaya-forks/feature/update-ts
Updated to latest typescript
2022-03-11 16:25:57 +01:00
Alejandro Celaya
faa81ea1a5 Merge pull request #598 from acelaya-forks/feature/footer-alignment
Feature/footer alignment
2022-03-11 16:22:00 +01:00
Alejandro Celaya
ec360d3a28 Updated to latest typescript 2022-03-11 16:20:38 +01:00
Alejandro Celaya
749074604f Added missing parentheses 2022-03-11 16:17:44 +01:00
Alejandro Celaya
c60a6a78c8 Updated changelog 2022-03-11 16:13:54 +01:00
Alejandro Celaya
f15b803851 Created sidebar reducer test 2022-03-11 16:12:54 +01:00
Alejandro Celaya
c949359d6f Renamed sidebar actions as they make more sense 2022-03-11 16:07:17 +01:00
Alejandro Celaya
73d4707420 Ensured versions footer has proper classes based on sidebar status, not selected server 2022-03-11 16:03:15 +01:00
Alejandro Celaya
4f731d9de8 Mitigated wrong footer alignment on some server sections 2022-03-10 19:13:39 +01:00
Alejandro Celaya
2b400beb31 Merge pull request #597 from shlinkio/dependabot/npm_and_yarn/urijs-1.19.10
Bump urijs from 1.19.9 to 1.19.10
2022-03-09 07:13:12 +01:00
dependabot[bot]
a3616b56f5 Bump urijs from 1.19.9 to 1.19.10
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.9 to 1.19.10.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.9...v1.19.10)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-07 22:09:44 +00:00
Alejandro Celaya
cf5205e976 Merge pull request #573 from acelaya-forks/feature/react-router-6
Feature/react router 6
2022-02-07 23:08:30 +01:00
Alejandro Celaya
eab072831d Fixed VisitsStats test 2022-02-07 22:55:32 +01:00
Alejandro Celaya
c4e928ff09 Fixed most tests using react-router-dom hooks 2022-02-07 22:17:57 +01:00
Alejandro Celaya
97024d828e Ensured short URLs section is active regardless the page 2022-02-06 21:17:10 +01:00
Alejandro Celaya
c6e500ba71 Updated source code to react-router 6 2022-02-06 20:07:18 +01:00
Alejandro Celaya
eb39d97cc5 Fixed merge conflicts 2022-02-05 16:48:55 +01:00
Alejandro Celaya
071eaddfd1 Merge pull request #571 from acelaya-forks/feature/non-orphan-visits
Feature/non orphan visits
2022-02-05 16:47:42 +01:00
Alejandro Celaya
0eec9b185f Added test for non-orphan visits reducer 2022-02-05 16:40:48 +01:00
Alejandro Celaya
5edb62e76b Created tests for non-orphan visits components 2022-02-05 16:37:01 +01:00
Alejandro Celaya
9bc5a050eb Updated changelog 2022-02-05 13:53:54 +01:00
Alejandro Celaya
4a80f224d8 Created components and reducer to handle non-orphan visits 2022-02-05 13:53:07 +01:00
Alejandro Celaya
0608d3cf19 Improved icon in HighlightedCard 2022-02-05 13:46:24 +01:00
Alejandro Celaya
8fbe6bb17d Added changes to load orphan visits and fixed tests 2022-02-05 13:37:49 +01:00
Alejandro Celaya
60929342fb Added some feedback to know which cardsin overview pages are clickable 2022-02-05 10:46:46 +01:00
Alejandro Celaya
e0d43020dc Extracted cards in overview to their own component 2022-02-05 10:04:34 +01:00
Alejandro Celaya
2de0276195 Added support for tag mode on short URLs list 2022-01-31 10:15:25 +01:00
Alejandro Celaya
1011b062ae Merge pull request #568 from acelaya-forks/feature/delete-error-code
Improved error code check on short URL deletion
2022-01-25 19:55:21 +01:00
Alejandro Celaya
c8b530cc1a Improved error code check on short URL deletion 2022-01-25 19:51:08 +01:00
Alejandro Celaya
6e72c343ab Merge pull request #566 from acelaya-forks/feature/nil-optimization
Fixed unintended usage of false where only null or undefined should m…
2022-01-08 15:14:29 +01:00
Alejandro Celaya
1c37186461 Fixed unintended usage of false where only null or undefined should match 2022-01-08 15:09:56 +01:00
Alejandro Celaya
34a59db4cf Merge pull request #558 from Roy-Orbison/tag-legibility
Make text of light tags legible
2022-01-08 12:45:14 +01:00
Alejandro Celaya
12f61d03be Created Tag component test 2022-01-08 12:41:32 +01:00
Alejandro Celaya
aca9218f9d Added test covering ColorGenerator.isColorLightForKey 2022-01-08 12:16:31 +01:00
Alejandro Celaya
b727a704a6 Changed classes to use BEM, and fixed TS compilation errors 2022-01-08 12:06:28 +01:00
Roy-Orbison
1e03eed6c0 Make text of light tags legible 2022-01-08 11:59:38 +01:00
Alejandro Celaya
e9fcdcb049 Merge pull request #563 from shlinkio/develop
Release 3.5.1
2022-01-08 11:25:22 +01:00
Alejandro Celaya
5b7f1ef18a Merge pull request #562 from acelaya-forks/feature/autocomplete-new-tags
Fixed new tags added to new short URLs, not appearing on tags autosug…
2022-01-08 11:21:32 +01:00
Alejandro Celaya
715128a653 Fixed new tags added to new short URLs, not appearing on tags autosuggest 2022-01-08 11:14:11 +01:00
Alejandro Celaya
83fbdbb135 Merge pull request #561 from acelaya-forks/feature/overview-list
Fixed short URLs list in overview page
2022-01-08 10:55:02 +01:00
Alejandro Celaya
2e963bdc8e Fixed short URLs list in overview page 2022-01-08 10:51:34 +01:00
Alejandro Celaya
8d6e93ea4f Merge pull request #560 from acelaya-forks/feature/logo-alignment
Feature/logo alignment
2022-01-08 10:30:26 +01:00
Alejandro Celaya
112a8cdf2f Updated changelog 2022-01-08 10:24:07 +01:00
Alejandro Celaya
27476d8b23 Added missing border in welcome screen title 2022-01-08 10:22:51 +01:00
Alejandro Celaya
2ad2d69b2b Fixed Shlink logo not being vertically aligned in welcome screen 2022-01-08 10:19:20 +01:00
Alejandro Celaya
a3d6944fc1 Added Twitter follow badge to readme 2022-01-07 16:16:53 +01:00
Alejandro Celaya
552169ee77 Merge pull request #553 from shlinkio/develop
Release 3.5.0
2022-01-01 12:50:47 +01:00
Alejandro Celaya
4f03ab18e5 Fixed typo in CHANGELOG 2022-01-01 12:47:54 +01:00
Alejandro Celaya
184d5d97e7 Merge pull request #552 from acelaya-forks/feature/duplicated-servers
Feature/duplicated servers
2022-01-01 12:43:31 +01:00
Alejandro Celaya
ba667a0768 Updated changelog 2022-01-01 12:38:00 +01:00
Alejandro Celaya
15b3424d7f Improved DuplicatedServersModal test 2022-01-01 12:35:06 +01:00
Alejandro Celaya
98398a048b Added logic to detect duplicated servers when importing a servers list 2022-01-01 12:20:09 +01:00
Alejandro Celaya
3cb066f5f5 Reduced unnecesary lines in test 2022-01-01 09:46:21 +01:00
Alejandro Celaya
053b38bee3 Created DuplicatedServerModal test 2022-01-01 09:46:21 +01:00
Alejandro Celaya
1f9356cc21 Created modal to warn when creating a duplicated server 2022-01-01 09:46:21 +01:00
Alejandro Celaya
f07e7fd31c Simplified server-related styles and removed default export from NoMenuLayout 2022-01-01 09:46:21 +01:00
Alejandro Celaya
7794876d7c Merge pull request #551 from acelaya-forks/feature/white-screen
Ensured settings migration function does not crash if settings are no…
2022-01-01 09:45:10 +01:00
Alejandro Celaya
e77b4d7a82 Ensured settings migration function does not crash if settings are not set 2022-01-01 09:40:26 +01:00
Alejandro Celaya
af0d2d3cdc Merge pull request #550 from acelaya-forks/feature/domain-health-checks
Feature/domain health checks
2021-12-28 23:33:33 +01:00
Alejandro Celaya
7e132be686 Fixed DomainStatusIcon test 2021-12-28 23:22:55 +01:00
Alejandro Celaya
aba1972d0d Added dynamic tooltip placement in DomainStatusIcon based on media query 2021-12-28 23:15:34 +01:00
Alejandro Celaya
0268bb6930 Improved icon used for failing status domains 2021-12-28 22:54:17 +01:00
Alejandro Celaya
ecd6e6a066 Created DomainStatusIcon test 2021-12-28 22:48:35 +01:00
Alejandro Celaya
6411c6169b Added tooltips to domain icons 2021-12-27 22:27:13 +01:00
Alejandro Celaya
a78467065a Added logic in ManageDomains and DomainRow components to check if the domains status 2021-12-26 13:53:17 +01:00
Alejandro Celaya
c05c74f009 Extended domainsList reducer, adding functionality to verify domains statuses 2021-12-26 13:38:17 +01:00
Alejandro Celaya
ace29ca4a4 Created helper function to replace the authority on a URL 2021-12-26 13:21:09 +01:00
Alejandro Celaya
4f90d147a4 Merge pull request #548 from acelaya-forks/feature/remove-extra-check
Removed error check which is no longer needed for currently supported…
2021-12-26 10:45:52 +01:00
Alejandro Celaya
9348f211f0 Removed error check which is no longer needed for currently supported Shlink versions 2021-12-26 10:42:25 +01:00
Alejandro Celaya
729d9e4a39 Merge pull request #546 from acelaya-forks/feature/order-by-to-query
Feature/order by to query
2021-12-25 20:04:40 +01:00
Alejandro Celaya
3274088b54 Added tests for new ordering helper functions and updated changelog 2021-12-25 19:58:54 +01:00
Alejandro Celaya
49c841ca07 Added short URLs orderBy handling to the query state 2021-12-25 19:51:25 +01:00
Alejandro Celaya
91f319df65 Merge pull request #545 from acelaya-forks/feature/refactorings
Feature/refactorings
2021-12-25 10:53:44 +01:00
Alejandro Celaya
dbf4b0926e Added Settings suffix to all settings sub-components 2021-12-25 10:49:12 +01:00
Alejandro Celaya
994f31b7e5 Renamed SortingDropdown to OrderingDropdown, for consistency 2021-12-25 10:32:33 +01:00
Alejandro Celaya
6213067f35 Removed default export in SortingDropdown 2021-12-25 10:26:38 +01:00
Alejandro Celaya
76fb45c97e Renamed constants holding orderable fields for short URLs and tags 2021-12-25 10:24:37 +01:00
Alejandro Celaya
2bf5f276f5 Merge pull request #544 from acelaya-forks/feature/ordering-settings
Feature/ordering settings
2021-12-24 15:21:04 +01:00
Alejandro Celaya
eaadd6f7af Removed params param when dispatching list short RULs action, as it was used by a reducer that has been deleted 2021-12-24 15:05:15 +01:00
Alejandro Celaya
86c6acb7b8 Updated changelog 2021-12-24 14:16:42 +01:00
Alejandro Celaya
de32d899bc Added new settings card to customize short URLs lists 2021-12-24 14:15:28 +01:00
Alejandro Celaya
d4356ba6e6 Moved types from old shortUrlsListParams reducer, to the data index file 2021-12-24 13:47:27 +01:00
Alejandro Celaya
275aee4de2 Removed shortUrlsListParams reducer, as the state is now handled internally in the component 2021-12-24 13:39:57 +01:00
Alejandro Celaya
57075c581d Updated Short URLs list so that it allows setting default orderBy from settings 2021-12-24 13:14:13 +01:00
Alejandro Celaya
d8442e435d Added option to customize ordering in tags list 2021-12-24 11:06:02 +01:00
Alejandro Celaya
e954a860bf Added test for migrateDeprecatedSettings function 2021-12-23 17:59:18 +01:00
Alejandro Celaya
5598fe0f53 Created new settings card for tags-related options 2021-12-23 17:53:14 +01:00
Alejandro Celaya
e77508edcc Merge pull request #541 from acelaya-forks/feature/not-empty-resultsets
Feature/not empty resultsets
2021-12-23 10:57:45 +01:00
Alejandro Celaya
c517c0521c Renamed doFallbackRange to doIntervalFallback to make it more descriptive 2021-12-23 10:51:13 +01:00
Alejandro Celaya
e22856ff74 Added logic in reducers to fallback to a different date interval if default one returns no visits 2021-12-23 10:38:02 +01:00
Alejandro Celaya
a30687e4ea Updated changelog 2021-12-22 20:34:56 +01:00
Alejandro Celaya
64ba346566 Updated VisitsStats components to react to the fallbackInterval 2021-12-22 20:23:26 +01:00
Alejandro Celaya
3745b297db Updated visits components to support the doFallbackRange flag 2021-12-22 20:19:54 +01:00
Alejandro Celaya
401418c049 Extended DateRangeSelector to allow updating its value via props after rendering 2021-12-22 20:14:26 +01:00
Alejandro Celaya
7adb40489d Added some helper function to deal with dates 2021-12-22 20:08:28 +01:00
Alejandro Celaya
482314b9f4 Merge pull request #540 from acelaya-forks/feature/extended-basic-creation-form
Added custom slug field to the basic creation form in Overview page
2021-12-19 12:56:21 +01:00
Alejandro Celaya
138e40315d Added custom slug field to the basic creation form in Overview page 2021-12-19 12:52:49 +01:00
Alejandro Celaya
7d6afd47b1 Removed unecesary check 2021-12-14 23:12:39 +01:00
Alejandro Celaya
ed1f650fc6 Merge pull request #539 from acelaya-forks/feature/dash-order-by
Switched to the <field>-<dir> notation in orderBy param for short URL…
2021-12-14 23:09:36 +01:00
Alejandro Celaya
17e4e06fcc Switched to the <field>-<dir> notation in orderBy param for short URLs list 2021-12-14 23:02:16 +01:00
Alejandro Celaya
654b36ab08 Merge pull request #536 from acelaya-forks/feature/default-domain-edition
Feature/default domain edition
2021-12-09 13:49:14 +01:00
Alejandro Celaya
9abbfc5b1e Updated changelog 2021-12-09 13:45:24 +01:00
Alejandro Celaya
c9d906316f Updated domain components to use defaultRedirects prop when present (Shlink 2.10 or newer) 2021-12-09 13:44:29 +01:00
Alejandro Celaya
8d476e0729 Added support to fetch full response from list domains endpoint 2021-12-09 13:16:28 +01:00
Alejandro Celaya
7a320c9574 Merge branch 'develop' of github.com:acelaya/shlink-web-client-react into develop 2021-12-09 13:08:50 +01:00
Alejandro Celaya
3f1392ce62 Fixed changelog 2021-12-09 13:08:19 +01:00
Alejandro Celaya
79e54ea230 Updated changelog 2021-12-08 08:53:10 +01:00
Alejandro Celaya
e2473207ba Merge pull request #534 from shlinkio/dependabot/npm_and_yarn/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2
2021-12-08 08:50:53 +01:00
dependabot[bot]
fb961dd47b Bump axios from 0.21.1 to 0.21.2
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-07 23:57:30 +00:00
Alejandro Celaya
9a62bcd8fb Merge branch 'develop' of github.com:shlinkio/shlink-web-client into develop 2021-12-07 20:51:45 +01:00
258 changed files with 9719 additions and 13454 deletions

View File

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

View File

@@ -17,6 +17,8 @@
"ignorePatterns": ["src/service*.ts"], "ignorePatterns": ["src/service*.ts"],
"rules": { "rules": {
"complexity": "off", "complexity": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off" "@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-call": "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

@@ -4,6 +4,85 @@ 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.6.0] - 2022-03-17
### Added
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV.
### Changed
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.
* [#567](https://github.com/shlinkio/shlink-web-client/pull/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs.
* [#448](https://github.com/shlinkio/shlink-web-client/pull/448) Updated to bootstrap v5.
* [#524](https://github.com/shlinkio/shlink-web-client/pull/524) Updated to react-router v6.
* [#576](https://github.com/shlinkio/shlink-web-client/pull/576) Updated to fontawesome v6.
* [#579](https://github.com/shlinkio/shlink-web-client/pull/579) Replaced react-color with react-colorful.
* [#564](https://github.com/shlinkio/shlink-web-client/pull/564) Updated most of the dependencies.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#589](https://github.com/shlinkio/shlink-web-client/pull/589) Fixed alignment of shlink versions footer, by basing the logic on the presence of the sidebar instead of selected server.
## [3.5.1] - 2022-01-08
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#555](https://github.com/shlinkio/shlink-web-client/issues/555) Fixed vertical alignment in welcome screen logo.
* [#554](https://github.com/shlinkio/shlink-web-client/issues/554) Fixed behavior in overview page, where items in the list of short URLs were stripped out when creating new ones, even if the amount of short URLs was still not yet big enough.
* [#557](https://github.com/shlinkio/shlink-web-client/issues/557) Fixed new tags added to new short URLs, not appearing on tags autosuggest.
## [3.5.0] - 2022-01-01
### Added
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
### Changed
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [3.4.2] - 2021-12-07 ## [3.4.2] - 2021-12-07
### 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 && 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>"

View File

@@ -5,6 +5,7 @@
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest) [![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE) [![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate) [![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io). A ReactJS-based progressive web application for [Shlink](https://shlink.io).

15
babel.config.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
presets: [
[
'react-app',
{
runtime: 'automatic',
typescript: true,
},
],
],
plugins: [
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-proposal-nullish-coalescing-operator',
],
};

View File

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

View File

@@ -15,37 +15,23 @@ module.exports = {
lines: 85, lines: 85,
}, },
}, },
resolver: 'jest-pnp-resolver', setupFiles: [ '<rootDir>/config/setupEnzyme.js' ],
setupFiles: [ testMatch: [ '<rootDir>/test/**/*.test.{ts,tsx}' ],
'react-app-polyfill/jsdom',
'<rootDir>/config/setupEnzyme.js',
],
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
testURL: 'http://localhost', testURL: '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', '^(?!.*\\.(ts|tsx|js|json)$)': '<rootDir>/config/jest/fileTransform.js',
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
}, },
transformIgnorePatterns: [ transformIgnorePatterns: [
'<rootDir>/.stryker-tmp',
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$', '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$', '^.+\\.module\\.scss$',
], ],
moduleNameMapper: { moduleNameMapper: {
'^react-native$': 'react-native-web', '^.+\\.module\\.scss$': 'identity-obj-proxy',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', // Reactstrap module resolution does not work in jest for some reason. Manually mapping it solves the problem
'reactstrap': '<rootDir>/node_modules/reactstrap/dist/reactstrap.umd.js',
}, },
moduleFileExtensions: [ moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ],
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node',
],
}; };

16631
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,8 @@
"lint:js:fix": "npm run lint:js -- --fix", "lint:js:fix": "npm run lint:js -- --fix",
"start": "node scripts/start.js", "start": "node scripts/start.js",
"serve:build": "serve ./build", "serve:build": "serve ./build",
"build": "node scripts/build.js", "build": "node scripts/build.js && node scripts/replace-version.js",
"build:dist": "npm run build && node scripts/create-dist-file.js",
"test": "node scripts/test.js --env=jsdom --colors --verbose", "test": "node scripts/test.js --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",
@@ -22,84 +23,81 @@
"mutate": "./node_modules/.bin/stryker run --concurrency 4" "mutate": "./node_modules/.bin/stryker run --concurrency 4"
}, },
"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.1", "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", "csvjson": "^5.1.0",
"date-fns": "^2.22.1", "date-fns": "^2.28.0",
"event-source-polyfill": "^1.0.22", "event-source-polyfill": "^1.0.25",
"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": "^17.0.2",
"react-chartjs-2": "^3.0.4", "react-chartjs-2": "^3.3.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": "^17.0.2",
"react-external-link": "^1.2.0", "react-external-link": "^1.2.2",
"react-leaflet": "^3.1.0", "react-leaflet": "^3.2.5",
"react-redux": "^7.2.2", "react-redux": "^7.2.6",
"react-router-dom": "^5.2.0", "react-router-dom": "^6.2.2",
"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.1.2",
"redux-localstorage-simple": "^2.4.0", "redux-localstorage-simple": "^2.4.1",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.4.1",
"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", "@babel/core": "^7.17.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
"@babel/plugin-proposal-optional-chaining": "^7.13.8", "@babel/plugin-proposal-optional-chaining": "^7.16.7",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2", "@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^5.4.1", "@stryker-mutator/core": "^5.6.1",
"@stryker-mutator/jest-runner": "^5.4.1", "@stryker-mutator/jest-runner": "^5.6.1",
"@stryker-mutator/typescript-checker": "^5.4.1", "@stryker-mutator/typescript-checker": "^5.6.1",
"@svgr/webpack": "^5.5.0", "@svgr/webpack": "^5.5.0",
"@types/classnames": "^2.2.11", "@types/classnames": "^2.3.1",
"@types/enzyme": "^3.10.10", "@types/enzyme": "^3.10.11",
"@types/jest": "^27.0.2", "@types/jest": "^27.4.1",
"@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": "^17.0.39",
"@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": "^17.0.13",
"@types/react-leaflet": "^2.5.2", "@types/react-leaflet": "^2.8.2",
"@types/react-redux": "^7.1.16", "@types/react-redux": "^7.1.23",
"@types/react-router-dom": "^5.1.7", "@types/react-tag-autocomplete": "^6.1.1",
"@types/react-tag-autocomplete": "^6.1.0", "@types/uuid": "^8.3.4",
"@types/uuid": "^8.3.0", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5", "adm-zip": "^0.5.9",
"adm-zip": "^0.4.16", "autoprefixer": "^10.4.2",
"autoprefixer": "^10.0.2", "babel-jest": "^27.5.1",
"babel-core": "7.0.0-bridge.0", "babel-loader": "^8.2.3",
"babel-jest": "^27.3.1", "babel-plugin-named-asset-import": "^0.3.8",
"babel-loader": "^8.2.1", "babel-preset-react-app": "10.0.0",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"bfj": "^7.0.2", "bfj": "^7.0.2",
"case-sensitive-paths-webpack-plugin": "^2.3.0", "case-sensitive-paths-webpack-plugin": "^2.4.0",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"css-loader": "^5.0.1", "css-loader": "^5.0.1",
"dart-sass": "^1.25.0", "dart-sass": "^1.25.0",
@@ -113,26 +111,22 @@
"fs-extra": "^9.0.1", "fs-extra": "^9.0.1",
"html-webpack-plugin": "^4.5.0", "html-webpack-plugin": "^4.5.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^27.3.1", "jest": "^27.5.1",
"jest-pnp-resolver": "^1.2.2",
"jest-resolve": "^27.3.1",
"mini-css-extract-plugin": "^1.3.1", "mini-css-extract-plugin": "^1.3.1",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"optimize-css-assets-webpack-plugin": "^5.0.4", "optimize-css-assets-webpack-plugin": "^5.0.4",
"pnp-webpack-plugin": "^1.6.4", "pnp-webpack-plugin": "^1.7.0",
"postcss": "^8.1.7", "postcss": "^8.4.8",
"postcss-flexbugs-fixes": "^4.2.1", "postcss-flexbugs-fixes": "^4.2.1",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.7.0",
"postcss-safe-parser": "^5.0.2", "postcss-safe-parser": "^5.0.2",
"raf": "^3.4.1",
"react-app-polyfill": "^2.0.0",
"react-dev-utils": "^11.0.0", "react-dev-utils": "^11.0.0",
"resolve": "^1.19.0", "resolve": "^1.22.0",
"sass": "^1.29.0", "sass": "^1.49.9",
"sass-loader": "^10.1.0", "sass-loader": "^10.1.0",
"serve": "^12.0.0", "serve": "^12.0.0",
"stryker-cli": "^1.0.0", "stryker-cli": "^1.0.2",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"stylelint": "^13.7.2", "stylelint": "^13.7.2",
"stylelint-config-adidas": "^1.3.0", "stylelint-config-adidas": "^1.3.0",
@@ -141,29 +135,14 @@
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
"sw-precache-webpack-plugin": "^1.0.0", "sw-precache-webpack-plugin": "^1.0.0",
"terser-webpack-plugin": "^4.2.3", "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", "url-loader": "^4.1.1",
"webpack": "^4.44.2", "webpack": "^4.46.0",
"webpack-dev-server": "^3.11.0", "webpack-dev-server": "^3.11.3",
"webpack-manifest-plugin": "^2.2.0", "webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.5.0", "whatwg-fetch": "^3.6.2",
"workbox-webpack-plugin": "^6.1.5" "workbox-webpack-plugin": "^6.5.1"
},
"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

@@ -18,7 +18,6 @@ const chalk = require('chalk');
const fs = require('fs-extra'); const fs = require('fs-extra');
const webpack = require('webpack'); const webpack = require('webpack');
const bfj = require('bfj'); const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
@@ -44,8 +43,6 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
const argvSliceStart = 2; const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart); const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.includes('--stats'); const writeStatsJson = argv.includes('--stats');
const withoutDist = argv.includes('--no-dist');
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration // Generate configuration
const config = configFactory('production'); const config = configFactory('production');
@@ -84,7 +81,6 @@ checkBrowsers(paths.appPath, isInteractive)
); );
} else { } else {
console.log(chalk.green('Compiled successfully.\n')); console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
} }
console.log('File sizes after gzip:\n'); console.log('File sizes after gzip:\n');
@@ -103,7 +99,6 @@ checkBrowsers(paths.appPath, isInteractive)
process.exit(1); process.exit(1);
}, },
) )
.then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => { .catch((err) => {
if (err && err.message) { if (err && err.message) {
console.log(err.message); console.log(err.message);
@@ -185,43 +180,3 @@ function copyPublicFolder() {
filter: (file) => file !== paths.appHtml, 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,36 @@
/* 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';
const chalk = require('chalk');
const AdmZip = require('adm-zip');
const fs = require('fs-extra');
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-extra');
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

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

View File

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

View File

@@ -1,7 +1,6 @@
import { Visit } from '../../visits/types'; import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
export interface ShlinkShortUrlsResponse { export interface ShlinkShortUrlsResponse {
data: ShortUrl[]; data: ShortUrl[];
@@ -84,8 +83,11 @@ export interface ShlinkDomain {
export interface ShlinkDomainsResponse { export interface ShlinkDomainsResponse {
data: ShlinkDomain[]; data: ShlinkDomain[];
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;
@@ -93,7 +95,12 @@ export interface ShlinkShortUrlsListParams {
searchTerm?: string; searchTerm?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
orderBy?: OrderBy; orderBy?: ShortUrlsOrder;
tagsMode?: TagsFilteringMode;
}
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
orderBy?: string;
} }
export interface ProblemDetailsError { export interface ProblemDetailsError {
@@ -110,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,5 +1,5 @@
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';
@@ -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;
@@ -26,7 +26,8 @@ const App = (
Settings: FC, Settings: 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={<Settings />} />
<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">

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',
@@ -18,7 +18,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'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

@@ -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 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

@@ -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';
@@ -28,8 +27,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 +40,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 +54,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

@@ -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;
@@ -12,19 +15,32 @@
} }
} }
.home__logo-wrapper {
padding: 1.5rem !important;
height: 100% !important;
min-height: 300px;
}
.home__logo { .home__logo {
@include vertical-align(); @include vertical-align();
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();
} }
} }
.home__title-wrapper {
padding: 1.5rem !important;
border-bottom: 1px solid var(--border-color);
}
.home__title { .home__title {
text-align: center; text-align: center;
font-size: 1.75rem; font-size: 1.75rem;

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,11 +10,12 @@ 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 { export interface HomeProps {
servers: ServersMap; servers: ServersMap;
} }
const Home = ({ servers, history }: HomeProps) => { const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate();
const serversList = values(servers); const serversList = values(servers);
const hasServers = !isEmpty(serversList); const hasServers = !isEmpty(serversList);
@@ -22,20 +23,22 @@ const Home = ({ servers, history }: HomeProps) => {
// 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 && history.push(`/server/${autoConnectServer.id}`); autoConnectServer && navigate(`/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="p-4"> <div className="home__logo-wrapper">
<ShlinkLogo /> <div className="home__logo">
<ShlinkLogo />
</div>
</div> </div>
</div> </div>
<div className="col-md-7 home__servers-container"> <div className="col-md-7 home__servers-container">
<div className="p-4"> <div className="home__title-wrapper">
<h1 className="home__title">Welcome!</h1> <h1 className="home__title">Welcome!</h1>
</div> </div>
<ServersListGroup embedded servers={serversList}> <ServersListGroup embedded servers={serversList}>
@@ -43,14 +46,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>

View File

@@ -1,16 +1,16 @@
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) => { 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 ]);
@@ -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>

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 {

View File

@@ -1,16 +1,21 @@
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, supportsNonOrphanVisits, supportsOrphanVisits } 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';
interface MenuLayoutProps {
sidebarPresent: Function;
sidebarNotPresent: Function;
}
const MenuLayout = ( const MenuLayout = (
TagsList: FC, TagsList: FC,
ShortUrlsList: FC, ShortUrlsList: FC,
@@ -19,20 +24,29 @@ const MenuLayout = (
ShortUrlVisits: FC, ShortUrlVisits: FC,
TagVisits: FC, TagVisits: 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 location = useLocation();
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); 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 addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer); const addManageDomainsRoute = supportsDomainRedirects(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 +60,23 @@ 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} />} {addOrphanVisitsRoute && <Route path="/orphan-visits/*" element={<OrphanVisits />} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} /> {addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />} <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>

View File

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

View File

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

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;
} }

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

@@ -0,0 +1,27 @@
import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
/* eslint-enable padding-line-between-statements */
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,33 @@
import { CsvJson } from 'csvjson';
import { NormalizedVisit } from '../../visits/types';
import { ExportableShortUrl } from '../../short-urls/data';
import { saveCsv } from '../../utils/helpers/files';
export class ReportExporter {
public constructor(
private readonly window: Window,
private readonly csvjson: CsvJson,
) {}
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.csvjson.toCSV(rows, { headers: 'key', wrap: true });
saveCsv(this.window, csv, filename);
};
}

View File

@@ -1,5 +1,5 @@
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';
@@ -9,26 +9,26 @@ 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', 'csvjson');
// 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(
@@ -41,20 +41,24 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ShortUrlVisits', 'ShortUrlVisits',
'TagVisits', 'TagVisits',
'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';
@@ -18,7 +17,8 @@ import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>; type LazyActionMap = Record<string, Function>;
const bottle = new Bottle(); const bottle = new Bottle();
const { container } = bottle;
export const { container } = bottle;
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) => const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
(...args: any[]) => (container[serviceName] as T)(...args) as K; (...args: any[]) => (container[serviceName] as T)(...args) as K;
@@ -33,16 +33,14 @@ 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);
provideMercureServices(bottle); provideMercureServices(bottle);
provideSettingsServices(bottle, connect); provideSettingsServices(bottle, connect);
provideDomainsServices(bottle, connect); provideDomainsServices(bottle, connect);
export default container;

View File

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

View File

@@ -4,7 +4,6 @@ import { Settings } from '../settings/reducers/settings';
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion'; import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition'; import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
import { TagDeletion } from '../tags/reducers/tagDelete'; import { TagDeletion } from '../tags/reducers/tagDelete';
import { TagEdition } from '../tags/reducers/tagEdit'; import { TagEdition } from '../tags/reducers/tagEdit';
@@ -15,18 +14,19 @@ 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';
export interface ShlinkState { export interface ShlinkState {
servers: ServersMap; servers: ServersMap;
selectedServer: SelectedServer; selectedServer: SelectedServer;
shortUrlsList: ShortUrlsList; shortUrlsList: ShortUrlsList;
shortUrlsListParams: ShortUrlsListParams;
shortUrlCreationResult: ShortUrlCreation; shortUrlCreationResult: ShortUrlCreation;
shortUrlDeletion: ShortUrlDeletion; shortUrlDeletion: ShortUrlDeletion;
shortUrlEdition: ShortUrlEdition; shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits; shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits; tagVisits: TagVisits;
orphanVisits: VisitsInfo; orphanVisits: VisitsInfo;
nonOrphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
tagsList: TagsList; tagsList: TagsList;
tagDelete: TagDeletion; tagDelete: TagDeletion;
@@ -36,6 +36,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,20 +1,26 @@
import { FC } from 'react'; import { FC, useEffect } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faBan as forbiddenIcon, faBan as forbiddenIcon,
faCheck as defaultDomainIcon, faDotCircle as defaultDomainIcon,
faEdit as editIcon, faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types'; import { ShlinkDomainRedirects } from '../api/types';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { OptionalString } from '../utils/utils'; import { OptionalString } from '../utils/utils';
import { SelectedServer } from '../servers/data';
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal'; import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
import { Domain } from './data';
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
interface DomainRowProps { interface DomainRowProps {
domain: ShlinkDomain; domain: Domain;
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
} }
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => ( const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
@@ -30,13 +36,20 @@ const DefaultDomain: FC = () => (
</> </>
); );
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => { export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
) => {
const [ isOpen, toggle ] = useToggle(); const [ isOpen, toggle ] = useToggle();
const { domain: authority, isDefault, redirects } = domain; const { domain: authority, isDefault, redirects, status } = domain;
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
useEffect(() => {
checkDomainHealth(domain.domain);
}, []);
return ( return (
<tr className="responsive-table__row"> <tr className="responsive-table__row">
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td> <td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
<th className="responsive-table__cell" data-th="Domain">{authority}</th> <th className="responsive-table__cell" data-th="Domain">{authority}</th>
<td className="responsive-table__cell" data-th="Base path redirect"> <td className="responsive-table__cell" data-th="Base path redirect">
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />} {redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
@@ -47,13 +60,16 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
<td className="responsive-table__cell" data-th="Invalid short URL redirect"> <td className="responsive-table__cell" data-th="Invalid short URL redirect">
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />} {redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
</td> </td>
<td className="responsive-table__cell text-right"> <td className="responsive-table__cell text-lg-center" data-th="Status">
<span id={isDefault ? 'defaultDomainBtn' : undefined}> <DomainStatusIcon status={status} />
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}> </td>
<FontAwesomeIcon fixedWidth icon={isDefault ? forbiddenIcon : editIcon} /> <td className="responsive-table__cell text-end">
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
</Button> </Button>
</span> </span>
{isDefault && ( {!canEditDomain && (
<UncontrolledTooltip target="defaultDomainBtn" placement="left"> <UncontrolledTooltip target="defaultDomainBtn" placement="left">
Redirects for default domain cannot be edited here. Redirects for default domain cannot be edited here.
<br /> <br />

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';
@@ -32,24 +31,22 @@ 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" 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
@@ -63,7 +60,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
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

@@ -5,6 +5,7 @@ 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 { DomainsList } from './reducers/domainsList'; import { DomainsList } from './reducers/domainsList';
import { DomainRow } from './DomainRow'; import { DomainRow } from './DomainRow';
@@ -12,16 +13,18 @@ interface ManageDomainsProps {
listDomains: Function; listDomains: Function;
filterDomains: (searchTerm: string) => void; filterDomains: (searchTerm: string) => void;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
domainsList: DomainsList; domainsList: DomainsList;
selectedServer: SelectedServer;
} }
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ]; const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
export const ManageDomains: FC<ManageDomainsProps> = ( export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects }, { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
) => { ) => {
const { filteredDomains: domains, loading, error, errorData } = domainsList; const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects; const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
useEffect(() => { useEffect(() => {
listDomains(); listDomains();
@@ -42,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>
@@ -53,7 +56,9 @@ export const ManageDomains: FC<ManageDomainsProps> = (
key={domain.domain} key={domain.domain}
domain={domain} domain={domain}
editDomainRedirects={editDomainRedirects} editDomainRedirects={editDomainRedirects}
defaultRedirects={defaultRedirects} checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer}
/> />
))} ))}
</tbody> </tbody>

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { FC, useState } from 'react'; import { 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"
@@ -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

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

View File

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

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;
} }
@@ -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

@@ -2,8 +2,8 @@ import { render } from 'react-dom';
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 { homepage } 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 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';

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,17 +13,19 @@ 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;

View File

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

View File

@@ -1,10 +0,0 @@
@import '../utils/base';
.create-server__label {
font-weight: 700;
cursor: pointer;
@media (min-width: $mdMin) {
text-align: right;
}
}

View File

@@ -1,18 +1,18 @@
import { FC } 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 } 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';
import './CreateServer.scss'; 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,29 +27,52 @@ 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 handleSubmit = (serverData: ServerData) => { const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
const [ serverData, setServerData ] = useState<ServerData | undefined>();
const save = () => {
if (!serverData) {
return;
}
const id = uuid(); const id = uuid();
createServer({ ...serverData, id }); createServer({ ...serverData, id });
push(`/server/${id}`); navigate(`/server/${id}`);
}; };
useEffect(() => {
const serverExists = Object.values(servers).some(
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
);
serverExists ? toggleConfirmModal() : save();
}, [ serverData ]);
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}> <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" />}
{errorImporting && <ImportResult type="error" />} {errorImporting && <ImportResult type="error" />}
<DuplicatedServersModal
isOpen={isConfirmModalOpen}
duplicatedServers={serverData ? [ serverData ] : []}
onDiscard={goBack}
onSave={save}
/>
</NoMenuLayout> </NoMenuLayout>
); );
}; };

View File

@@ -1,6 +1,6 @@
import { FC } from 'react'; import { FC } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { 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 (

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

@@ -3,7 +3,7 @@ import { Button, Row } from 'reactstrap';
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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';
@@ -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,7 +58,7 @@ 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 style={{ width: '50px' }} />}

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

@@ -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,7 +1,7 @@
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 { 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';
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable'; import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
@@ -11,8 +11,9 @@ import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Versions } from '../utils/helpers/version'; 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, supportsOrphanVisits } 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;
@@ -41,10 +42,12 @@ 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 linkToOrphanVisits = supportsOrphanVisits(selectedServer);
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } }); listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
listTags(); listTags();
loadVisitsOverview(); loadVisitsOverview();
}, []); }, []);
@@ -52,45 +55,38 @@ 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={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}>
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle> <ForServerVersion minVersion="2.6.0">
<CardText tag="h2"> {loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
<ForServerVersion minVersion="2.6.0"> </ForServerVersion>
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)} <ForServerVersion maxVersion="2.5.*">
</ForServerVersion> <small className="text-muted"><i>Shlink 2.6 is needed</i></small>
<ForServerVersion maxVersion="2.5.*"> </ForServerVersion>
<small className="text-muted"><i>Shlink 2.6 is needed</i></small> </HighlightCard>
</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,14 +96,14 @@ 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>

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

@@ -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

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

View File

@@ -0,0 +1,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 } 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 interface HighlightCardProps {
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,10 +1,12 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react'; import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { 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';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersImporter from '../services/ServersImporter'; import { useToggle } from '../../utils/helpers/hooks';
import { ServerData } from '../data'; import { ServersImporter } from '../services/ServersImporter';
import { ServerData, ServersMap } from '../data';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import './ImportServersBtn.scss'; import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>; type Ref<T> = RefObject<T> | MutableRefObject<T>;
@@ -18,11 +20,16 @@ export interface ImportServersBtnProps {
interface ImportServersBtnConnectProps extends ImportServersBtnProps { interface ImportServersBtnConnectProps extends ImportServersBtnProps {
createServers: (servers: ServerData[]) => void; createServers: (servers: ServerData[]) => void;
servers: ServersMap;
fileRef: Ref<HTMLInputElement>; fileRef: Ref<HTMLInputElement>;
} }
const serversFiltering = (servers: ServerData[]) =>
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
createServers, createServers,
servers,
fileRef, fileRef,
children, children,
onImport = () => {}, onImport = () => {},
@@ -31,15 +38,37 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
className = '', className = '',
}) => { }) => {
const ref = fileRef ?? useRef<HTMLInputElement>(); const ref = fileRef ?? useRef<HTMLInputElement>();
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) => const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
const [ isModalOpen,, showModal, hideModal ] = useToggle();
const create = pipe(createServers, onImport);
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
const createNonDuplicatedServers = pipe(
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
hideModal,
);
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
importServersFromFile(target.files?.[0]) importServersFromFile(target.files?.[0])
.then(pipe(createServers, onImport)) .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;
}) })
.catch(onImportError); .catch(onImportError);
useEffect(() => {
if (!serversToCreate) {
return;
}
const existingServers = Object.values(servers);
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
const hasDuplicatedServers = !!duplicatedServers.length;
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
hasDuplicatedServers && showModal();
}, [ serversToCreate ]);
return ( return (
<> <>
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}> <Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
@@ -49,7 +78,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>. You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip> </UncontrolledTooltip>
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onChange} /> <input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onFile} />
<DuplicatedServersModal
isOpen={isModalOpen}
duplicatedServers={duplicatedServers}
onDiscard={createNonDuplicatedServers}
onSave={createAllServers}
/>
</> </>
); );
}; };

View File

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

View File

@@ -1,3 +0,0 @@
.server-form .form-group:last-child {
margin-bottom: 0;
}

View File

@@ -1,9 +1,8 @@
import { FC, ReactNode, useEffect, useState } from 'react'; import { FC, 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 { interface ServerFormProps {
onSubmit: (server: ServerData) => void; onSubmit: (server: ServerData) => void;
@@ -11,9 +10,6 @@ interface ServerFormProps {
title?: ReactNode; title?: ReactNode;
} }
const FormGroup: FC<FormGroupContainerProps> = (props) =>
<FormGroupContainer {...props} labelClassName="create-server__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('');
@@ -29,12 +25,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
return ( return (
<form className="server-form" onSubmit={handleSubmit}> <form className="server-form" 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

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

View File

@@ -7,7 +7,7 @@ const validateServer = (server: any): server is ServerData =>
const validateServers = (servers: any): servers is ServerData[] => const validateServers = (servers: any): servers is ServerData[] =>
Array.isArray(servers) && servers.every(validateServer); Array.isArray(servers) && servers.every(validateServer);
export default class ServersImporter { export class ServersImporter {
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {} public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => { public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {

View File

@@ -1,5 +1,5 @@
import csvjson from 'csvjson'; import csvjson from 'csvjson';
import Bottle, { Decorator } from 'bottlejs'; import Bottle 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';
@@ -17,10 +17,10 @@ import { Overview } from '../Overview';
import { ManageServers } from '../ManageServers'; import { ManageServers } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow'; import { ManageServersRow } from '../ManageServersRow';
import { ManageServersRowDropdown } from '../ManageServersRowDropdown'; 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,7 +30,8 @@ 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');
@@ -42,19 +43,18 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
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(null, [ 'createServers' ])); bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion); bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ])); bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));

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 {
@@ -12,22 +14,23 @@ interface RealTimeUpdatesProps {
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`; const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
const RealTimeUpdates = ( const RealTimeUpdatesSettings = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps, { settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => ( ) => (
<SimpleCard title="Real-time updates" className="h-100"> <SimpleCard title="Real-time updates" className="h-100">
<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,17 +40,17 @@ const RealTimeUpdates = (
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>
); );
export default RealTimeUpdates; export default RealTimeUpdatesSettings;

View File

@@ -1,29 +1,35 @@
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-6 mb-3">
{subChild}
</div>
))}
</Row>
))}
</> </>
); );
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => ( const Settings = (
RealTimeUpdates: FC,
ShortUrlCreation: FC,
ShortUrlsList: FC,
UserInterface: FC,
Visits: 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 />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key <NavPillItem to="other-items">Other items</NavPillItem>
]} </NavPills>
/>
<Routes>
<Route path="general" element={<SettingsSections items={[ <UserInterface key="one" />, <RealTimeUpdates key="two" /> ]} />} />
<Route path="short-urls" element={<SettingsSections items={[ <ShortUrlCreation key="one" />, <ShortUrlsList key="two" /> ]} />} />
<Route path="other-items" element={<SettingsSections items={[ <Tags key="one" />, <Visits key="two" /> ]} />} />
<Route path="*" element={<Navigate replace to="general" />} />
</Routes>
</NoMenuLayout> </NoMenuLayout>
); );

View File

@@ -3,11 +3,13 @@ import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch'; import ToggleSwitch from '../utils/ToggleSwitch';
import { DropdownBtn } from '../utils/DropdownBtn'; import { DropdownBtn } from '../utils/DropdownBtn';
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings'; import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
interface ShortUrlCreationProps { interface ShortUrlCreationProps {
settings: Settings; settings: Settings;
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void; setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
} }
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string => const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
@@ -17,8 +19,8 @@ const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): R
? <>The list of suggested tags will contain those <b>including</b> provided input.</> ? <>The list of suggested tags will contain those <b>including</b> provided input.</>
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>; : <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => { export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false }; const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings( const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode }, { ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
); );
@@ -31,10 +33,10 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })} onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
> >
Request validation on long URLs when creating new short URLs. Request validation on long URLs when creating new short URLs.
<small className="form-text text-muted"> <FormText>
The initial state of the <b>Validate URL</b> checkbox will The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>. be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</small> </FormText>
</ToggleSwitch> </ToggleSwitch>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -43,14 +45,13 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })} onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
> >
Make all new short URLs forward their query params to the long URL. Make all new short URLs forward their query params to the long URL.
<small className="form-text text-muted"> <FormText>
The initial state of the <b>Forward query params on redirect</b> checkbox will The initial state of the <b>Forward query params on redirect</b> checkbox will
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>. be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
</small> </FormText>
</ToggleSwitch> </ToggleSwitch>
</FormGroup> </FormGroup>
<FormGroup className="mb-0"> <LabeledFormGroup noMargin label="Tag suggestions search mode:">
<label>Tag suggestions search mode:</label>
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}> <DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
<DropdownItem <DropdownItem
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'} active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
@@ -65,10 +66,8 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
{tagFilteringModeText('includes')} {tagFilteringModeText('includes')}
</DropdownItem> </DropdownItem>
</DropdownBtn> </DropdownBtn>
<small className="form-text text-muted"> <FormText>{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}</FormText>
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)} </LabeledFormGroup>
</small>
</FormGroup>
</SimpleCard> </SimpleCard>
); );
}; };

View File

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

View File

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

View File

@@ -1,44 +0,0 @@
import { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { Settings, UiSettings } from './reducers/settings';
import './UserInterface.scss';
interface UserInterfaceProps {
settings: Settings;
setUiSettings: (settings: UiSettings) => void;
}
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
<SimpleCard title="User interface" className="h-100">
<FormGroup>
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={ui?.theme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ ...ui, theme });
changeThemeInMarkup(theme);
}}
>
Use dark theme.
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label>Default display mode when managing tags:</label>
<TagsModeDropdown
mode={ui?.tagsMode ?? 'cards'}
renderTitle={(tagsMode) => capitalize(tagsMode)}
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
/>
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
</FormGroup>
</SimpleCard>
);

View File

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

View File

@@ -1,23 +1,22 @@
import { FormGroup } from 'reactstrap';
import { FC } from 'react'; import { FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector'; import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { Settings, VisitsSettings } from './reducers/settings'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
interface VisitsProps { interface VisitsProps {
settings: Settings; settings: Settings;
setVisitsSettings: (settings: VisitsSettings) => void; setVisitsSettings: (settings: VisitsSettingsConfig) => void;
} }
export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => ( export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<SimpleCard title="Visits" className="h-100"> <SimpleCard title="Visits" className="h-100">
<FormGroup className="mb-0"> <LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<label>Default interval to load on visits sections:</label>
<DateIntervalSelector <DateIntervalSelector
allText="All visits" allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'} active={settings.visits?.defaultInterval ?? 'last30Days'}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })} onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/> />
</FormGroup> </LabeledFormGroup>
</SimpleCard> </SimpleCard>
); );

View File

@@ -0,0 +1,21 @@
import { ShlinkState } from '../../container/types';
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
if (!state.settings) {
return state;
}
// The "last180Days" interval had a typo, with a lowercase d
if ((state.settings.visits?.defaultInterval as any) === 'last180days') {
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
}
// The "tags display mode" option has been moved from "ui" to "tags"
state.settings.tags = {
...state.settings.tags,
defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode,
};
state.settings.ui && delete (state.settings.ui as any).tagsMode;
return state;
};

View File

@@ -4,9 +4,16 @@ import { buildReducer } from '../../utils/helpers/redux';
import { RecursivePartial } from '../../utils/utils'; import { RecursivePartial } from '../../utils/utils';
import { Theme } from '../../utils/theme'; import { Theme } from '../../utils/theme';
import { DateInterval } from '../../utils/dates/types'; import { DateInterval } from '../../utils/dates/types';
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
import { ShortUrlsOrder } from '../../short-urls/data';
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
field: 'dateCreated',
dir: 'DESC',
};
/** /**
* Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as * Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as
* optional, as old instances of the app will load partial objects from local storage until it is saved again. * optional, as old instances of the app will load partial objects from local storage until it is saved again.
@@ -29,18 +36,28 @@ export type TagsMode = 'cards' | 'list';
export interface UiSettings { export interface UiSettings {
theme: Theme; theme: Theme;
tagsMode?: TagsMode;
} }
export interface VisitsSettings { export interface VisitsSettings {
defaultInterval: DateInterval; defaultInterval: DateInterval;
} }
export interface TagsSettings {
defaultOrdering?: TagsOrder;
defaultMode?: TagsMode;
}
export interface ShortUrlsListSettings {
defaultOrdering?: ShortUrlsOrder;
}
export interface Settings { export interface Settings {
realTimeUpdates: RealTimeUpdatesSettings; realTimeUpdates: RealTimeUpdatesSettings;
shortUrlCreation?: ShortUrlCreationSettings; shortUrlCreation?: ShortUrlCreationSettings;
shortUrlsList?: ShortUrlsListSettings;
ui?: UiSettings; ui?: UiSettings;
visits?: VisitsSettings; visits?: VisitsSettings;
tags?: TagsSettings;
} }
const initialState: Settings = { const initialState: Settings = {
@@ -56,6 +73,9 @@ const initialState: Settings = {
visits: { visits: {
defaultInterval: 'last30Days', defaultInterval: 'last30Days',
}, },
shortUrlsList: {
defaultOrdering: DEFAULT_SHORT_URLS_ORDERING,
},
}; };
type SettingsAction = Action & Settings; type SettingsAction = Action & Settings;
@@ -81,6 +101,11 @@ export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings):
shortUrlCreation: settings, shortUrlCreation: settings,
}); });
export const setShortUrlsListSettings = (settings: ShortUrlsListSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
shortUrlsList: settings,
});
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({ export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
type: SET_SETTINGS, type: SET_SETTINGS,
ui: settings, ui: settings,
@@ -90,3 +115,8 @@ export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsActi
type: SET_SETTINGS, type: SET_SETTINGS,
visits: settings, visits: settings,
}); });
export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
tags: settings,
});

View File

@@ -1,46 +1,67 @@
import Bottle from 'bottlejs'; import Bottle from 'bottlejs';
import RealTimeUpdates from '../RealTimeUpdates'; import RealTimeUpdatesSettings from '../RealTimeUpdatesSettings';
import Settings from '../Settings'; import Settings from '../Settings';
import { import {
setRealTimeUpdatesInterval, setRealTimeUpdatesInterval,
setShortUrlCreationSettings, setShortUrlCreationSettings,
setShortUrlsListSettings,
setTagsSettings,
setUiSettings, setUiSettings,
setVisitsSettings, setVisitsSettings,
toggleRealTimeUpdates, toggleRealTimeUpdates,
} from '../reducers/settings'; } from '../reducers/settings';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ShortUrlCreation } from '../ShortUrlCreation'; import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
import { UserInterface } from '../UserInterface'; import { UserInterfaceSettings } from '../UserInterfaceSettings';
import { Visits } from '../Visits'; import { VisitsSettings } from '../VisitsSettings';
import { TagsSettings } from '../TagsSettings';
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits'); bottle.serviceFactory(
'Settings',
Settings,
'RealTimeUpdatesSettings',
'ShortUrlCreationSettings',
'ShortUrlsListSettings',
'UserInterfaceSettings',
'VisitsSettings',
'TagsSettings',
);
bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates); bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
bottle.decorator( bottle.decorator(
'RealTimeUpdates', 'RealTimeUpdatesSettings',
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]), connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
); );
bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation); bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ])); bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
bottle.serviceFactory('UserInterface', () => UserInterface); bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ])); bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ]));
bottle.serviceFactory('Visits', () => Visits); bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ])); bottle.decorator('VisitsSettings', connect([ 'settings' ], [ 'setVisitsSettings' ]));
bottle.serviceFactory('TagsSettings', () => TagsSettings);
bottle.decorator('TagsSettings', connect([ 'settings' ], [ 'setTagsSettings' ]));
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ]));
// Actions // Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings); bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings);
bottle.serviceFactory('setUiSettings', () => setUiSettings); bottle.serviceFactory('setUiSettings', () => setUiSettings);
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings); bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
}; };
export default provideServices; export default provideServices;

View File

@@ -1,9 +1,9 @@
import { FC, useEffect, useMemo } from 'react'; import { FC, useEffect, useMemo } from 'react';
import { RouteComponentProps } from 'react-router';
import { Button, Card } from 'reactstrap'; import { Button, Card } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { OptionalString } from '../utils/utils'; import { OptionalString } from '../utils/utils';
@@ -11,13 +11,13 @@ import { parseQuery } from '../utils/helpers/query';
import Message from '../utils/Message'; 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 { useToggle } from '../utils/helpers/hooks'; import { useGoBack, useToggle } from '../utils/helpers/hooks';
import { ShortUrlFormProps } from './ShortUrlForm'; import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail'; import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data'; import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
import { ShortUrlEdition } from './reducers/shortUrlEdition'; import { ShortUrlEdition } from './reducers/shortUrlEdition';
interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> { interface EditShortUrlConnectProps {
settings: Settings; settings: Settings;
selectedServer: SelectedServer; selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
@@ -48,9 +48,6 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
}; };
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
history: { goBack },
match: { params },
location: { search },
settings: { shortUrlCreation: shortUrlCreationSettings }, settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer, selectedServer,
shortUrlDetail, shortUrlDetail,
@@ -58,6 +55,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
shortUrlEdition, shortUrlEdition,
editShortUrl, editShortUrl,
}: EditShortUrlConnectProps) => { }: EditShortUrlConnectProps) => {
const { search } = useLocation();
const params = useParams<{ shortCode: string }>();
const goBack = useGoBack();
const { loading, error, errorData, shortUrl } = shortUrlDetail; const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition; const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
@@ -68,7 +68,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle(); const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
useEffect(() => { useEffect(() => {
getShortUrlDetail(params.shortCode, domain); params.shortCode && getShortUrlDetail(params.shortCode, domain);
}, []); }, []);
if (loading) { if (loading) {
@@ -88,7 +88,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
<header className="mb-3"> <header className="mb-3">
<Card body> <Card body>
<h2 className="d-sm-flex justify-content-between align-items-center mb-0"> <h2 className="d-sm-flex justify-content-between align-items-center mb-0">
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}> <Button color="link" size="lg" className="p-0 me-3" onClick={goBack}>
<FontAwesomeIcon icon={faArrowLeft} /> <FontAwesomeIcon icon={faArrowLeft} />
</Button> </Button>
<span className="text-center"> <span className="text-center">

View File

@@ -1,3 +0,0 @@
.search-bar__tags-icon {
vertical-align: bottom;
}

View File

@@ -1,70 +0,0 @@
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda';
import { parseISO } from 'date-fns';
import { RouteChildrenProps } from 'react-router-dom';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date';
import ColorGenerator from '../utils/services/ColorGenerator';
import { DateRange } from '../utils/dates/types';
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
import './SearchBar.scss';
export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
const selectedTags = tags?.split(',') ?? [];
const setDates = pipe(
({ startDate, endDate }: DateRange) => ({
startDate: formatIsoDate(startDate) ?? undefined,
endDate: formatIsoDate(endDate) ?? undefined,
}),
toFirstPage,
);
const setSearch = pipe(
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
(search) => toFirstPage({ search }),
);
const removeTag = pipe(
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
(tags) => toFirstPage({ tags }),
);
return (
<div className="search-bar-container">
<SearchField initialValue={search} onChange={setSearch} />
<div className="mt-3">
<div className="row">
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={{
startDate: dateOrNull(startDate),
endDate: dateOrNull(endDate),
}}
onDatesChange={setDates}
/>
</div>
</div>
</div>
{selectedTags.length > 0 && (
<h4 className="search-bar__selected-tag mt-3">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) =>
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
</h4>
)}
</div>
);
};
export default SearchBar;

View File

@@ -1,6 +1,5 @@
@import '../utils/base'; @import '../utils/base';
.short-url-form .card-body > .form-group:last-child,
.short-url-form p:last-child { .short-url-form p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@@ -1,5 +1,5 @@
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/lib/Input'; import { InputType } from 'reactstrap/types/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap'; import { Button, FormGroup, Input, Row } from 'reactstrap';
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda'; import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -41,6 +41,7 @@ export const ShortUrlForm = (
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { ): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
const [ shortUrlData, setShortUrlData ] = useState(initialState); const [ shortUrlData, setShortUrlData ] = useState(initialState);
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const isBasicMode = mode === 'create-basic';
const hadTitleOriginally = hasValue(initialState.title); const hadTitleOriginally = hasValue(initialState.title);
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlData(initialState); const reset = () => setShortUrlData(initialState);
@@ -66,8 +67,14 @@ export const ShortUrlForm = (
setShortUrlData(initialState); setShortUrlData(initialState);
}, [ initialState ]); }, [ initialState ]);
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => ( const renderOptionalInput = (
<FormGroup> id: NonDateFields,
placeholder: string,
type: InputType = 'text',
props = {},
fromGroupProps = {},
) => (
<FormGroup {...fromGroupProps}>
<Input <Input
id={id} id={id}
type={type} type={type}
@@ -79,15 +86,13 @@ export const ShortUrlForm = (
</FormGroup> </FormGroup>
); );
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => ( const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group"> <DateInput
<DateInput selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null} placeholderText={placeholder}
placeholderText={placeholder} isClearable
isClearable onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })} {...props}
{...props} />
/>
</div>
); );
const basicComponents = ( const basicComponents = (
<> <>
@@ -101,10 +106,12 @@ export const ShortUrlForm = (
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })} onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
/> />
</FormGroup> </FormGroup>
<Row>
<FormGroup> {isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} /> <div className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
</FormGroup> <TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
</div>
</Row>
</> </>
); );
@@ -118,8 +125,8 @@ export const ShortUrlForm = (
return ( return (
<form className="short-url-form" onSubmit={submit}> <form className="short-url-form" onSubmit={submit}>
{mode === 'create-basic' && basicComponents} {isBasicMode && basicComponents}
{mode !== 'create-basic' && ( {!isBasicMode && (
<> <>
<SimpleCard title="Basic options" className="mb-3"> <SimpleCard title="Basic options" className="mb-3">
{basicComponents} {basicComponents}
@@ -145,12 +152,10 @@ export const ShortUrlForm = (
})} })}
</div> </div>
</Row> </Row>
<FormGroup> <DomainSelector
<DomainSelector value={shortUrlData.domain}
value={shortUrlData.domain} onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })} />
/>
</FormGroup>
</> </>
)} )}
</SimpleCard> </SimpleCard>
@@ -160,7 +165,9 @@ export const ShortUrlForm = (
<div className={limitAccessCardClasses}> <div className={limitAccessCardClasses}>
<SimpleCard title="Limit access to the short URL"> <SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })} <div className="mb-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
</div>
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })} {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
</SimpleCard> </SimpleCard>
</div> </div>
@@ -180,7 +187,7 @@ export const ShortUrlForm = (
<p> <p>
<Checkbox <Checkbox
inline inline
className="mr-2" className="me-2"
checked={shortUrlData.findIfExists} checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })} onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
> >

View File

@@ -0,0 +1,3 @@
.short-urls-filtering-bar__tags-icon {
vertical-align: bottom;
}

View File

@@ -0,0 +1,105 @@
import { FC } from 'react';
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda';
import { parseISO } from 'date-fns';
import { Row } from 'reactstrap';
import classNames from 'classnames';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date';
import ColorGenerator from '../utils/services/ColorGenerator';
import { DateRange } from '../utils/dates/types';
import { supportsAllTagsFiltering } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch';
import { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import './ShortUrlsFilteringBar.scss';
export interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string;
shortUrlsAmount?: number;
}
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const ShortUrlsFilteringBar = (
colorGenerator: ColorGenerator,
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery();
const setDates = pipe(
({ startDate, endDate }: DateRange) => ({
startDate: formatIsoDate(startDate) ?? undefined,
endDate: formatIsoDate(endDate) ?? undefined,
}),
toFirstPage,
);
const setSearch = pipe(
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
(search) => toFirstPage({ search }),
);
const removeTag = pipe(
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
(updateTags) => toFirstPage({ tags: updateTags }),
);
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const toggleTagsMode = pipe(
() => tagsMode === 'any' ? 'all' : 'any',
(tagsMode) => toFirstPage({ tagsMode }),
);
return (
<div className={classNames('short-urls-filtering-bar-container', className)}>
<SearchField initialValue={search} onChange={setSearch} />
<Row className="flex-column-reverse flex-lg-row">
<div className="col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} />
</div>
<div className="col-12 d-block d-lg-none mt-3">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} />
</div>
<div className="col-lg-8 col-xl-6 mt-3">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={{
startDate: dateOrNull(startDate),
endDate: dateOrNull(endDate),
}}
onDatesChange={setDates}
/>
</div>
</Row>
{tags.length > 0 && (
<h4 className="mt-3">
{canChangeTagsMode && tags.length > 1 && (
<div className="float-end ms-2 mt-1">
<TooltipToggleSwitch
checked={tagsMode === 'all'}
tooltip={{ placement: 'left' }}
onChange={toggleTagsMode}
>
{tagsMode === 'all' ? 'Short URLs including all tags.' : 'Short URLs including any tag.'}
</TooltipToggleSwitch>
</div>
)}
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon me-1" />
{tags.map((tag) =>
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
</h4>
)}
</div>
);
};
export default ShortUrlsFilteringBar;

View File

@@ -1,78 +1,75 @@
import { head, keys, pipe, values } from 'ramda'; import { pipe } from 'ramda';
import { FC, useEffect, useMemo, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import SortingDropdown from '../utils/SortingDropdown'; import { useLocation, useParams } from 'react-router-dom';
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering'; import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
import { getServerId, SelectedServer } from '../servers/data'; import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { ShlinkShortUrlsListParams } from '../api/types'; import { ShlinkShortUrlsListParams } from '../api/types';
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
import { ShortUrlsTableProps } from './ShortUrlsTable'; import { ShortUrlsTableProps } from './ShortUrlsTable';
import Paginator from './Paginator'; import Paginator from './Paginator';
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> { interface ShortUrlsListProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void; listShortUrls: (params: ShlinkShortUrlsListParams) => void;
shortUrlsListParams: ShortUrlsListParams; settings: Settings;
resetShortUrlParams: () => void;
} }
type ShortUrlsOrder = Order<OrderableFields>; const ShortUrlsList = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({ ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
listShortUrls, ) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
resetShortUrlParams,
shortUrlsListParams,
match,
location,
history,
shortUrlsList,
selectedServer,
}: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const { orderBy } = shortUrlsListParams; const { page } = useParams();
const [ order, setOrder ] = useState<ShortUrlsOrder>({ const location = useLocation();
field: orderBy && (head(keys(orderBy)) as OrderableFields), const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery();
dir: orderBy && head(values(orderBy)), const [ actualOrderBy, setActualOrderBy ] = useState(
}); // This separated state handling is needed to be able to fall back to settings value, but only once when loaded
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location }); orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); );
const { pagination } = shortUrlsList?.shortUrls ?? {}; const { pagination } = shortUrlsList?.shortUrls ?? {};
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls( toFirstPage({ orderBy: { field, dir } });
{ ...shortUrlsListParams, ...extraParams }, setActualOrderBy({ field, dir });
);
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
setOrder({ field, dir });
refreshList({ orderBy: field ? { [field]: dir } : undefined });
}; };
const orderByColumn = (field: OrderableFields) => () => const orderByColumn = (field: ShortUrlsOrderableFields) => () =>
handleOrderBy(field, determineOrderDir(field, order.field, order.dir)); handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir));
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />; const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
const addTag = pipe( const addTag = pipe(
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','), (newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
(tags) => toFirstPage({ tags }), (updatedTags) => toFirstPage({ tags: updatedTags }),
); );
useEffect(() => resetShortUrlParams, []);
useEffect(() => { useEffect(() => {
refreshList( listShortUrls({
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate }, page,
); searchTerm: search,
}, [ match.params.page, search, selectedTags, startDate, endDate ]); tags,
startDate,
endDate,
orderBy: actualOrderBy,
tagsMode,
});
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
return ( return (
<> <>
<div className="mb-3"><SearchBar /></div> <ShortUrlsFilteringBar
<div className="d-block d-lg-none mb-3"> selectedServer={selectedServer}
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} /> shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
</div> order={actualOrderBy}
handleOrderBy={handleOrderBy}
className="mb-3"
/>
<Card body className="pb-1"> <Card body className="pb-1">
<ShortUrlsTable <ShortUrlsTable
selectedServer={selectedServer} selectedServer={selectedServer}

View File

@@ -5,12 +5,12 @@ import { SelectedServer } from '../servers/data';
import { supportsShortUrlTitle } from '../utils/helpers/features'; import { supportsShortUrlTitle } from '../utils/helpers/features';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow'; import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
import { OrderableFields } from './reducers/shortUrlsListParams'; import { ShortUrlsOrderableFields } from './data';
import './ShortUrlsTable.scss'; import './ShortUrlsTable.scss';
export interface ShortUrlsTableProps { export interface ShortUrlsTableProps {
orderByColumn?: (column: OrderableFields) => () => void; orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
renderOrderIcon?: (column: OrderableFields) => ReactNode; renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
selectedServer: SelectedServer; selectedServer: SelectedServer;
onTagClick?: (tag: string) => void; onTagClick?: (tag: string) => void;
@@ -28,7 +28,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
const { error, loading, shortUrls } = shortUrlsList; const { error, loading, shortUrls } = shortUrlsList;
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn }); const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses); const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
const tableClasses = classNames('table table-hover', className); const tableClasses = classNames('table table-hover responsive-table', className);
const supportsTitle = supportsShortUrlTitle(selectedServer); const supportsTitle = supportsShortUrlTitle(selectedServer);
const renderShortUrls = () => { const renderShortUrls = () => {

View File

@@ -1,4 +1,5 @@
import { Nullable, OptionalString } from '../../utils/utils'; import { Nullable, OptionalString } from '../../utils/utils';
import { Order } from '../../utils/helpers/ordering';
export interface EditShortUrlData { export interface EditShortUrlData {
longUrl?: string; longUrl?: string;
@@ -50,3 +51,24 @@ export interface ShortUrlIdentifier {
shortCode: string; shortCode: string;
domain: OptionalString; domain: OptionalString;
} }
export const SHORT_URLS_ORDERABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
longUrl: 'Long URL',
title: 'Title',
visits: 'Visits',
};
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
export interface ExportableShortUrl {
createdAt: string;
title: string;
shortUrl: string;
longUrl: string;
tags: string;
visits: number;
}

View File

@@ -28,7 +28,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
if (error) { if (error) {
return ( return (
<Result type="error" className="mt-3"> <Result type="error" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />} {canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" /> <ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
</Result> </Result>
); );
@@ -42,7 +42,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
return ( return (
<Result type="success" className="mt-3"> <Result type="success" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />} {canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
<b>Great!</b> The short URL is <b>{shortUrl}</b> <b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}> <CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>

View File

@@ -0,0 +1,61 @@
import { FC } from 'react';
import { ExportBtn } from '../../utils/ExportBtn';
import { useToggle } from '../../utils/helpers/hooks';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { isServerWithId, SelectedServer } from '../../servers/data';
import { ShortUrl } from '../data';
import { ReportExporter } from '../../common/services/ReportExporter';
import { useShortUrlsQuery } from './hooks';
export interface ExportShortUrlsBtnProps {
amount?: number;
}
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
selectedServer: SelectedServer;
}
const itemsPerPage = 20;
export const ExportShortUrlsBtn = (
buildShlinkApiClient: ShlinkApiClientBuilder,
{ exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [ loading,, startLoading, stopLoading ] = useToggle();
const exportAllUrls = async () => {
if (!isServerWithId(selectedServer)) {
return;
}
const totalPages = amount / itemsPerPage;
const { listShortUrls } = buildShlinkApiClient(selectedServer);
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
const { data } = await listShortUrls(
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
);
if (page >= totalPages) {
return data;
}
// TODO Support paralelization
return data.concat(await loadAllUrls(page + 1));
};
startLoading();
const shortUrls = await loadAllUrls();
exportShortUrls(shortUrls.map((shortUrl) => ({
createdAt: shortUrl.dateCreated,
shortUrl: shortUrl.shortUrl,
longUrl: shortUrl.longUrl,
title: shortUrl.title ?? '',
tags: shortUrl.tags.join(','),
visits: shortUrl.visitsCount,
})));
stopLoading();
};
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
};

View File

@@ -3,7 +3,6 @@ import { Modal, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstra
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import classNames from 'classnames';
import { ShortUrlModalProps } from '../data'; import { ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
@@ -56,10 +55,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Row> <Row>
<FormGroup <FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })} <label>Size: {size}px</label>
>
<label className="mb-0">Size: {size}px</label>
<input <input
type="range" type="range"
className="form-control-range" className="form-control-range"
@@ -71,8 +68,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
/> />
</FormGroup> </FormGroup>
{capabilities.marginIsSupported && ( {capabilities.marginIsSupported && (
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}> <FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<label className="mb-0">Margin: {margin}px</label> <label>Margin: {margin}px</label>
<input <input
type="range" type="range"
className="form-control-range" className="form-control-range"
@@ -106,7 +103,7 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
color="primary" color="primary"
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)} onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
> >
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" /> Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button> </Button>
</div> </div>
</ForServerVersion> </ForServerVersion>

View File

@@ -12,7 +12,7 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
{ children, infoTooltip, checked, onChange }, { children, infoTooltip, checked, onChange },
) => ( ) => (
<p> <p>
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}> <Checkbox inline checked={checked} className={infoTooltip ? 'me-2' : ''} onChange={onChange}>
{children} {children}
</Checkbox> </Checkbox>
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>} {infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}

View File

@@ -59,7 +59,7 @@ const ShortUrlsRow = (
<span className="indivisible short-urls-row__cell--relative"> <span className="indivisible short-urls-row__cell--relative">
<ExternalLink href={shortUrl.shortUrl} /> <ExternalLink href={shortUrl.shortUrl} />
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} /> <CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}> <span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
Copied short URL! Copied short URL!
</span> </span>
</span> </span>
@@ -73,7 +73,7 @@ const ShortUrlsRow = (
</td> </td>
)} )}
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td> <td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits"> <td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
<ShortUrlVisitsCount <ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount} visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl} shortUrl={shortUrl}

View File

@@ -1,30 +1,63 @@
import { RouteChildrenProps } from 'react-router-dom'; import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isEmpty } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
import { TagsFilteringMode } from '../../api/types';
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>; type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
type ToFirstPage = (extra: Partial<ShortUrlsQuery>) => void;
export interface ShortUrlListRouteParams { export interface ShortUrlListRouteParams {
page: string; page: string;
serverId: string; serverId: string;
} }
interface ShortUrlsQuery { interface ShortUrlsQueryCommon {
tags?: string;
search?: string; search?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
tagsMode?: TagsFilteringMode;
} }
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => { interface ShortUrlsQuery extends ShortUrlsQueryCommon {
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]); orderBy?: string;
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => { tags?: string;
const evolvedQuery = stringifyQuery({ ...query, ...extra }); }
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder;
tags: string[];
}
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const navigate = useNavigate();
const location = useLocation();
const params = useParams<{ serverId: string }>();
const query = useMemo(
pipe(
() => parseQuery<ShortUrlsQuery>(location.search),
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
const parsedTags = tags?.split(',') ?? [];
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
},
),
[ location.search ],
);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
const normalizedQuery: ShortUrlsQuery = {
...mergedQuery,
orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined,
};
const evolvedQuery = stringifyQuery(normalizedQuery);
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`; const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`); navigate(`/server/${params.serverId}/list-short-urls/1${queryString}`);
}; };
return [ query, toFirstPageWithExtra ]; return [ query, toFirstPageWithExtra ];

View File

@@ -1,4 +1,4 @@
import { assoc, assocPath, init, last, pipe, reject } from 'ramda'; import { assoc, assocPath, last, pipe, reject } from 'ramda';
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../helpers'; import { shortUrlMatches } from '../helpers';
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
@@ -7,7 +7,6 @@ import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types'; import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { ShortUrlsListParams } from './shortUrlsListParams';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
@@ -17,6 +16,8 @@ export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS'; export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export const ITEMS_IN_OVERVIEW_PAGE = 5;
export interface ShortUrlsList { export interface ShortUrlsList {
shortUrls?: ShlinkShortUrlsResponse; shortUrls?: ShlinkShortUrlsResponse;
loading: boolean; loading: boolean;
@@ -25,7 +26,6 @@ export interface ShortUrlsList {
export interface ListShortUrlsAction extends Action<string> { export interface ListShortUrlsAction extends Action<string> {
shortUrls: ShlinkShortUrlsResponse; shortUrls: ShlinkShortUrlsResponse;
params: ShortUrlsListParams;
} }
export type ListShortUrlsCombinedAction = ( export type ListShortUrlsCombinedAction = (
@@ -77,10 +77,11 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
), ),
[CREATE_SHORT_URL]: pipe( [CREATE_SHORT_URL]: pipe(
// The only place where the list and the creation form coexist is the overview page. // The only place where the list and the creation form coexist is the overview page.
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one. // There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
// We can also remove the items above the amount that is displayed there.
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath( (state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
[ result, ...init(state.shortUrls.data) ], [ result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1) ],
state, state,
), ),
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath( (state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
@@ -109,8 +110,8 @@ export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
try { try {
const shortUrls = await listShortUrls(params); const shortUrls = await listShortUrls(params);
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls, params }); dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
} catch (e) { } catch (e) {
dispatch({ type: LIST_SHORT_URLS_ERROR, params }); dispatch({ type: LIST_SHORT_URLS_ERROR });
} }
}; };

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