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
./.stryker-tmp
./build
./coverage
./node_modules

View File

@@ -17,6 +17,8 @@
"ignorePatterns": ["src/service*.ts"],
"rules": {
"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:
node-version: 16.13
- name: Generate release assets
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest
env:

View File

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

View File

@@ -2,8 +2,7 @@ FROM node:16.13-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist
RUN cd /shlink-web-client && npm ci && npm run build
FROM nginx:1.21-alpine
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)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io).

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,
},
},
resolver: 'jest-pnp-resolver',
setupFiles: [
'react-app-polyfill/jsdom',
'<rootDir>/config/setupEnzyme.js',
],
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
setupFiles: [ '<rootDir>/config/setupEnzyme.js' ],
testMatch: [ '<rootDir>/test/**/*.test.{ts,tsx}' ],
testEnvironment: 'jsdom',
testURL: 'http://localhost',
transform: {
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
'^(?!.*\\.(ts|tsx|js|json)$)': '<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [
'<rootDir>/.stryker-tmp',
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$',
'^.+\\.module\\.scss$',
],
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
'^.+\\.module\\.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: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node',
],
moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ],
};

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",
"start": "node scripts/start.js",
"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:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
@@ -22,84 +23,81 @@
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.2",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-regular-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"axios": "^0.21.1",
"bootstrap": "^4.6.0",
"@fortawesome/fontawesome-free": "^6.0.0",
"@fortawesome/fontawesome-svg-core": "^1.3.0",
"@fortawesome/free-regular-svg-icons": "^6.0.0",
"@fortawesome/free-solid-svg-icons": "^6.0.0",
"@fortawesome/react-fontawesome": "^0.1.17",
"axios": "^0.26.0",
"bootstrap": "^5.1.3",
"bottlejs": "^2.0.0",
"bowser": "^2.11.0",
"chart.js": "^3.5.1",
"classnames": "^2.2.6",
"compare-versions": "^3.6.0",
"chart.js": "^3.7.1",
"classnames": "^2.3.1",
"compare-versions": "^4.1.3",
"csvjson": "^5.1.0",
"date-fns": "^2.22.1",
"event-source-polyfill": "^1.0.22",
"date-fns": "^2.28.0",
"event-source-polyfill": "^1.0.25",
"leaflet": "^1.7.1",
"promise": "^8.1.0",
"qs": "^6.9.6",
"ramda": "^0.27.1",
"react": "^17.0.1",
"react-chartjs-2": "^3.0.4",
"react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "^3.6.0",
"react-dom": "^17.0.1",
"react-external-link": "^1.2.0",
"react-leaflet": "^3.1.0",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-swipeable": "^6.0.1",
"react-tag-autocomplete": "^6.1.0",
"reactstrap": "^8.9.0",
"redux": "^4.0.5",
"redux-localstorage-simple": "^2.4.0",
"redux-thunk": "^2.3.0",
"ramda": "^0.27.2",
"react": "^17.0.2",
"react-chartjs-2": "^3.3.0",
"react-colorful": "^5.5.1",
"react-copy-to-clipboard": "^5.0.4",
"react-datepicker": "^4.7.0",
"react-dom": "^17.0.2",
"react-external-link": "^1.2.2",
"react-leaflet": "^3.2.5",
"react-redux": "^7.2.6",
"react-router-dom": "^6.2.2",
"react-swipeable": "^6.2.0",
"react-tag-autocomplete": "^6.3.0",
"reactstrap": "^9.0.1",
"redux": "^4.1.2",
"redux-localstorage-simple": "^2.4.1",
"redux-thunk": "^2.4.1",
"uuid": "^8.3.2",
"workbox-core": "^6.1.5",
"workbox-expiration": "^6.1.5",
"workbox-precaching": "^6.1.5",
"workbox-routing": "^6.1.5",
"workbox-strategies": "^6.1.5"
"workbox-core": "^6.5.1",
"workbox-expiration": "^6.5.1",
"workbox-precaching": "^6.5.1",
"workbox-routing": "^6.5.1",
"workbox-strategies": "^6.5.1"
},
"devDependencies": {
"@babel/core": "^7.13.8",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@babel/core": "^7.17.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^5.4.1",
"@stryker-mutator/jest-runner": "^5.4.1",
"@stryker-mutator/typescript-checker": "^5.4.1",
"@stryker-mutator/core": "^5.6.1",
"@stryker-mutator/jest-runner": "^5.6.1",
"@stryker-mutator/typescript-checker": "^5.6.1",
"@svgr/webpack": "^5.5.0",
"@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.10",
"@types/jest": "^27.0.2",
"@types/leaflet": "^1.5.23",
"@types/qs": "^6.9.5",
"@types/ramda": "^0.27.38",
"@types/react": "^17.0.2",
"@types/react-color": "^3.0.4",
"@types/react-copy-to-clipboard": "^5.0.0",
"@types/react-datepicker": "^3.1.5",
"@types/react-dom": "^17.0.1",
"@types/react-leaflet": "^2.5.2",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"@types/react-tag-autocomplete": "^6.1.0",
"@types/uuid": "^8.3.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"adm-zip": "^0.4.16",
"autoprefixer": "^10.0.2",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^27.3.1",
"babel-loader": "^8.2.1",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0",
"@types/classnames": "^2.3.1",
"@types/enzyme": "^3.10.11",
"@types/jest": "^27.4.1",
"@types/leaflet": "^1.7.9",
"@types/qs": "^6.9.7",
"@types/ramda": "0.27.38",
"@types/react": "^17.0.39",
"@types/react-color": "^3.0.6",
"@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^17.0.13",
"@types/react-leaflet": "^2.8.2",
"@types/react-redux": "^7.1.23",
"@types/react-tag-autocomplete": "^6.1.1",
"@types/uuid": "^8.3.4",
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
"adm-zip": "^0.5.9",
"autoprefixer": "^10.4.2",
"babel-jest": "^27.5.1",
"babel-loader": "^8.2.3",
"babel-plugin-named-asset-import": "^0.3.8",
"babel-preset-react-app": "10.0.0",
"babel-runtime": "^6.26.0",
"bfj": "^7.0.2",
"case-sensitive-paths-webpack-plugin": "^2.3.0",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"chalk": "^4.1.2",
"css-loader": "^5.0.1",
"dart-sass": "^1.25.0",
@@ -113,26 +111,22 @@
"fs-extra": "^9.0.1",
"html-webpack-plugin": "^4.5.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.3.1",
"jest-pnp-resolver": "^1.2.2",
"jest-resolve": "^27.3.1",
"jest": "^27.5.1",
"mini-css-extract-plugin": "^1.3.1",
"object-assign": "^4.1.1",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"pnp-webpack-plugin": "^1.6.4",
"postcss": "^8.1.7",
"pnp-webpack-plugin": "^1.7.0",
"postcss": "^8.4.8",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"postcss-safe-parser": "^5.0.2",
"raf": "^3.4.1",
"react-app-polyfill": "^2.0.0",
"react-dev-utils": "^11.0.0",
"resolve": "^1.19.0",
"sass": "^1.29.0",
"resolve": "^1.22.0",
"sass": "^1.49.9",
"sass-loader": "^10.1.0",
"serve": "^12.0.0",
"stryker-cli": "^1.0.0",
"stryker-cli": "^1.0.2",
"style-loader": "^2.0.0",
"stylelint": "^13.7.2",
"stylelint-config-adidas": "^1.3.0",
@@ -141,29 +135,14 @@
"stylelint-scss": "^3.18.0",
"sw-precache-webpack-plugin": "^1.0.0",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^27.0.7",
"ts-mockery": "^1.2.0",
"typescript": "^4.4.4",
"typescript": "^4.6.2",
"url-loader": "^4.1.1",
"webpack": "^4.44.2",
"webpack-dev-server": "^3.11.0",
"webpack": "^4.46.0",
"webpack-dev-server": "^3.11.3",
"webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.5.0",
"workbox-webpack-plugin": "^6.1.5"
},
"babel": {
"presets": [
[
"react-app",
{
"runtime": "automatic"
}
]
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
"whatwg-fetch": "^3.6.2",
"workbox-webpack-plugin": "^6.5.1"
},
"browserslist": [
">0.2%",

View File

@@ -18,7 +18,6 @@ const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
@@ -44,8 +43,6 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.includes('--stats');
const withoutDist = argv.includes('--no-dist');
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration
const config = configFactory('production');
@@ -84,7 +81,6 @@ checkBrowsers(paths.appPath, isInteractive)
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
}
console.log('File sizes after gzip:\n');
@@ -103,7 +99,6 @@ checkBrowsers(paths.appPath, isInteractive)
process.exit(1);
},
)
.then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => {
if (err && err.message) {
console.log(err.message);
@@ -185,43 +180,3 @@ function copyPublicFolder() {
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' {
export declare class CsvJson {
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,
ShlinkVisitsParams,
ShlinkShortUrlData,
ShlinkDomain,
ShlinkDomainsResponse,
ShlinkVisitsOverview,
ShlinkEditDomainRedirects,
ShlinkDomainRedirects,
ShlinkShortUrlsListParams,
ShlinkShortUrlsListNormalizedParams,
} from '../types';
import { stringifyQuery } from '../../utils/helpers/query';
import { orderToString } from '../../utils/helpers/ordering';
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : '';
const rejectNilProps = reject(isNil);
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
const { orderBy = {}, ...rest } = params;
return { ...rest, orderBy: orderToString(orderBy) };
};
export default class ShlinkApiClient {
private apiVersion: number;
public constructor(
private readonly axios: AxiosInstance,
private readonly baseUrl: string,
private readonly apiKey: string,
) {
this.apiVersion = 2;
}
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
.then(({ data }) => data.shortUrls);
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
@@ -57,6 +60,10 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
.then(({ data }) => data.visits);
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);
@@ -69,7 +76,10 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.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 (
shortCode: string,
domain: OptionalString,
@@ -107,43 +117,21 @@ export default class ShlinkApiClient {
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
.then((resp) => resp.data);
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
public readonly editDomainRedirects = async (
domainRedirects: ShlinkEditDomainRedirects,
): Promise<ShlinkDomainRedirects> =>
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
try {
return await this.axios({
method,
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
headers: { 'X-Api-Key': this.apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: stringifyQuery,
});
} catch (e: 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);
}
};
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
this.axios({
method,
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
headers: { 'X-Api-Key': this.apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: stringifyQuery,
});
}

View File

@@ -1,7 +1,6 @@
import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
export interface ShlinkShortUrlsResponse {
data: ShortUrl[];
@@ -84,8 +83,11 @@ export interface ShlinkDomain {
export interface ShlinkDomainsResponse {
data: ShlinkDomain[];
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
}
export type TagsFilteringMode = 'all' | 'any';
export interface ShlinkShortUrlsListParams {
page?: string;
itemsPerPage?: number;
@@ -93,7 +95,12 @@ export interface ShlinkShortUrlsListParams {
searchTerm?: string;
startDate?: string;
endDate?: string;
orderBy?: OrderBy;
orderBy?: ShortUrlsOrder;
tagsMode?: TagsFilteringMode;
}
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
orderBy?: string;
}
export interface ProblemDetailsError {
@@ -110,6 +117,6 @@ export interface InvalidArgumentError extends ProblemDetailsError {
}
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
type: 'INVALID_SHORTCODE_DELETION';
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
threshold: number;
}

View File

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

View File

@@ -1,5 +1,5 @@
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 NotFound from '../common/NotFound';
import { ServersMap } from '../servers/data';
@@ -9,7 +9,7 @@ import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
interface AppProps extends RouteChildrenProps {
interface AppProps {
fetchServers: () => void;
servers: ServersMap;
settings: Settings;
@@ -26,7 +26,8 @@ const App = (
Settings: FC,
ManageServers: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
const location = useLocation();
const isHome = location.pathname === '/';
useEffect(() => {
@@ -44,15 +45,15 @@ const App = (
<div className="app">
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/manage-servers" component={ManageServers} />
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
<Routes>
<Route index element={<Home />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId/*" element={<MenuLayout />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<div className="shlink-footer">

View File

@@ -1,9 +1,9 @@
import Bottle, { Decorator } from 'bottlejs';
import Bottle from 'bottlejs';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
import App from '../App';
import { ConnectDecorator } from '../../container/types';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory(
'App',
@@ -18,7 +18,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
bottle.decorator('App', withRouter);
// Actions
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>
<p className="mb-0">
Restart it to enjoy the new features.
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
<Button disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
{isUpdating && <>Restarting...</>}
</Button>
</p>

View File

@@ -8,9 +8,8 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
import classNames from 'classnames';
import { Location } from 'history';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features';
@@ -28,8 +27,7 @@ interface AsideMenuItemProps extends NavLinkProps {
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
<NavLink
className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected"
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
to={to}
{...rest}
>
@@ -42,11 +40,11 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
) => {
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation();
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
return (
@@ -56,7 +54,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<AsideMenuItem
to={buildPath('/list-short-urls/1')}
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
>
<FontAwesomeIcon fixedWidth icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</AsideMenuItem>

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,7 @@
@import '../utils/base';
.menu-layout__swipeable {
$offset: 15px;
height: 100%;
margin-right: -$offset;
margin-left: -$offset;
padding-left: $offset;
padding-right: $offset;
}
.menu-layout__swipeable-inner {

View File

@@ -1,16 +1,21 @@
import { FC, useEffect } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
import { supportsDomainRedirects, supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data';
import NotFound from './NotFound';
import { AsideMenuProps } from './AsideMenu';
import './MenuLayout.scss';
interface MenuLayoutProps {
sidebarPresent: Function;
sidebarNotPresent: Function;
}
const MenuLayout = (
TagsList: FC,
ShortUrlsList: FC,
@@ -19,20 +24,29 @@ const MenuLayout = (
ShortUrlVisits: FC,
TagVisits: FC,
OrphanVisits: FC,
NonOrphanVisits: FC,
ServerError: FC,
Overview: FC,
EditShortUrl: FC,
ManageDomains: FC,
) => withSelectedServer(({ location, selectedServer }) => {
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
const location = useLocation();
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
const showContent = isReachableServer(selectedServer);
useEffect(() => hideSidebar(), [ location ]);
useEffect(() => {
showContent && sidebarPresent();
if (!isReachableServer(selectedServer)) {
return () => sidebarNotPresent();
}, []);
if (!showContent) {
return <ServerError />;
}
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
@@ -46,21 +60,23 @@ const MenuLayout = (
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="menu-layout__container" onClick={() => hideSidebar()}>
<div className="container-xl">
<Switch>
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
<Route exact path="/server/:serverId/overview" component={Overview} />
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
<Routes>
<Route index element={<Navigate replace to="overview" />} />
<Route path="/overview" element={<Overview />} />
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
<Route path="/create-short-url" element={<CreateShortUrl />} />
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
{addOrphanVisitsRoute && <Route path="/orphan-visits/*" element={<OrphanVisits />} />}
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} />
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
path="*"
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Switch>
</Routes>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
import { pipe } from 'ramda';
import { ExternalLink } from 'react-external-link';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
import { isReachableServer } from '../servers/data';
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
import { isReachableServer, SelectedServer } from '../servers/data';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
export interface ShlinkVersionsProps {
selectedServer: SelectedServer;
clientVersion?: string;
}

View File

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

View File

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

View File

@@ -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 Bottle, { Decorator } from 'bottlejs';
import Bottle from 'bottlejs';
import ScrollToTop from '../ScrollToTop';
import MainHeader from '../MainHeader';
import Home from '../Home';
@@ -9,26 +9,26 @@ import ErrorHandler from '../ErrorHandler';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson');
// Components
bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
bottle.decorator('MainHeader', withRouter);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', withRouter);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory(
@@ -41,20 +41,24 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ShortUrlVisits',
'TagVisits',
'OrphanVisits',
'NonOrphanVisits',
'ServerError',
'Overview',
'EditShortUrl',
'ManageDomains',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer', 'sidebarPresent', 'sidebarNotPresent' ]));
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer', 'sidebar' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
// Actions
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
};
export default provideServices;

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,26 @@
import { FC } from 'react';
import { FC, useEffect } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBan as forbiddenIcon,
faCheck as defaultDomainIcon,
faDotCircle as defaultDomainIcon,
faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
import { ShlinkDomainRedirects } from '../api/types';
import { useToggle } from '../utils/helpers/hooks';
import { OptionalString } from '../utils/utils';
import { SelectedServer } from '../servers/data';
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
import { Domain } from './data';
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
interface DomainRowProps {
domain: ShlinkDomain;
domain: Domain;
defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
}
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
@@ -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 { domain: authority, isDefault, redirects } = domain;
const { domain: authority, isDefault, redirects, status } = domain;
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
useEffect(() => {
checkDomainHealth(domain.domain);
}, []);
return (
<tr className="responsive-table__row">
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
<td className="responsive-table__cell" data-th="Base path redirect">
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
@@ -47,13 +60,16 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
</td>
<td className="responsive-table__cell text-right">
<span id={isDefault ? 'defaultDomainBtn' : undefined}>
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
<FontAwesomeIcon fixedWidth icon={isDefault ? forbiddenIcon : editIcon} />
<td className="responsive-table__cell text-lg-center" data-th="Status">
<DomainStatusIcon status={status} />
</td>
<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>
</span>
{isDefault && (
{!canEditDomain && (
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
Redirects for default domain cannot be edited here.
<br />

View File

@@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
import { InputProps } from 'reactstrap/lib/Input';
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { isEmpty, pipe } from 'ramda';
@@ -32,24 +31,22 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
return inputDisplayed ? (
<InputGroup>
<Input
value={value}
value={value ?? ''}
placeholder="Domain"
onChange={(e) => onChange(e.target.value)}
/>
<InputGroupAddon addonType="append">
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroupAddon>
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroup>
) : (
<DropdownBtn
@@ -63,7 +60,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
onClick={() => onChange(domain)}
>
{domain}
{isDefault && <span className="float-right text-muted">default</span>}
{isDefault && <span className="float-end text-muted">default</span>}
</DropdownItem>
))}
<DropdownItem divider />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { homepage } from '../package.json';
import container from './container';
import store from './container/store';
import { container } from './container';
import { store } from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import 'react-datepicker/dist/react-datepicker.css';

View File

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

View File

@@ -2,13 +2,13 @@ import { combineReducers } from 'redux';
import serversReducer from '../servers/reducers/servers';
import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits';
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
@@ -18,19 +18,20 @@ import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import appUpdatesReducer from '../app/reducers/appUpdates';
import sidebarReducer from '../common/reducers/sidebar';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
servers: serversReducer,
selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer,
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
orphanVisits: orphanVisitsReducer,
nonOrphanVisits: nonOrphanVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
@@ -40,4 +41,5 @@ export default combineReducers<ShlinkState>({
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
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 { RouterProps } from 'react-router';
import { Button } from 'reactstrap';
import { useNavigate } from 'react-router-dom';
import { Result } from '../utils/Result';
import NoMenuLayout from '../common/NoMenuLayout';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServersMap, ServerWithId } from './data';
import './CreateServer.scss';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps extends RouterProps {
interface CreateServerProps {
createServer: (server: ServerWithId) => void;
servers: ServersMap;
}
@@ -27,29 +27,52 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
);
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 [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const handleSubmit = (serverData: ServerData) => {
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
const [ serverData, setServerData ] = useState<ServerData | undefined>();
const save = () => {
if (!serverData) {
return;
}
const id = uuid();
createServer({ ...serverData, id });
push(`/server/${id}`);
navigate(`/server/${id}`);
};
useEffect(() => {
const serverExists = Object.values(servers).some(
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
);
serverExists ? toggleConfirmModal() : save();
}, [ serverData ]);
return (
<NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
{!hasServers &&
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ml-2">Create server</Button>
<Button outline color="primary" className="ms-2">Create server</Button>
</ServerForm>
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
<DuplicatedServersModal
isOpen={isConfirmModalOpen}
duplicatedServers={serverData ? [ serverData ] : []}
onDiscard={goBack}
onSave={save}
/>
</NoMenuLayout>
);
};

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { Button, Row } from 'reactstrap';
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import NoMenuLayout from '../common/NoMenuLayout';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField';
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">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{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
</Button>
)}
</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">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button>
@@ -58,7 +58,7 @@ export const ManageServers = (
</Row>
<SimpleCard>
<table className="table table-hover mb-0">
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th style={{ width: '50px' }} />}

View File

@@ -31,7 +31,7 @@ export const ManageServersRow = (
<Link to={`/server/${server.id}`}>{server.name}</Link>
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-right">
<td className="responsive-table__cell text-end">
<ManageServersRowDropdown server={server} />
</td>
</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 { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
import { Link, useHistory } from 'react-router-dom';
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import { Link, useNavigate } from 'react-router-dom';
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
@@ -11,8 +11,9 @@ import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Versions } from '../utils/helpers/version';
import { Topics } from '../mercure/helpers/Topics';
import { ShlinkShortUrlsListParams } from '../api/types';
import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
import { getServerId, SelectedServer } from './data';
import './Overview.scss';
import { HighlightCard } from './helpers/HighlightCard';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
@@ -41,10 +42,12 @@ export const Overview = (
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const serverId = getServerId(selectedServer);
const history = useHistory();
const linkToOrphanVisits = supportsOrphanVisits(selectedServer);
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
const navigate = useNavigate();
useEffect(() => {
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
listTags();
loadVisitsOverview();
}, []);
@@ -52,45 +55,38 @@ export const Overview = (
return (
<>
<Row>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body>
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
</HighlightCard>
</div>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/orphan-visits`}>
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
<CardText tag="h2">
<ForServerVersion minVersion="2.6.0">
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
</ForServerVersion>
<ForServerVersion maxVersion="2.5.*">
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
</ForServerVersion>
</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Orphan visits" link={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}>
<ForServerVersion minVersion="2.6.0">
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
</ForServerVersion>
<ForServerVersion maxVersion="2.5.*">
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
</ForServerVersion>
</HighlightCard>
</div>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}>
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
<CardText tag="h2">
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</HighlightCard>
</div>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/manage-tags`}>
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
</HighlightCard>
</div>
</Row>
<Card className="mb-3">
<CardHeader>
<span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5>
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader>
<CardBody>
<CreateShortUrl basicMode />
@@ -100,14 +96,14 @@ export const Overview = (
<CardHeader>
<span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader>
<CardBody>
<ShortUrlsTable
shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
className="mb-0"
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/>
</CardBody>
</Card>

View File

@@ -17,7 +17,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
if (isEmpty(serversList)) {
return (
<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>
);
}
@@ -31,7 +31,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
))}
<DropdownItem divider />
<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>
</>
);
@@ -40,9 +40,9 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
</DropdownToggle>
<DropdownMenu right>{renderServers()}</DropdownMenu>
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};

View File

@@ -17,6 +17,10 @@
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item:not(:hover) {
color: $mainColor;
}
.servers-list__server-item:hover {
background-color: var(--secondary-color);
}

View File

@@ -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 { pipe } from 'ramda';
import { complement, pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersImporter from '../services/ServersImporter';
import { ServerData } from '../data';
import { useToggle } from '../../utils/helpers/hooks';
import { ServersImporter } from '../services/ServersImporter';
import { ServerData, ServersMap } from '../data';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>;
@@ -18,11 +20,16 @@ export interface ImportServersBtnProps {
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
createServers: (servers: ServerData[]) => void;
servers: ServersMap;
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> => ({
createServers,
servers,
fileRef,
children,
onImport = () => {},
@@ -31,15 +38,37 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
className = '',
}) => {
const ref = fileRef ?? useRef<HTMLInputElement>();
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
const [ isModalOpen,, showModal, hideModal ] = useToggle();
const create = pipe(createServers, onImport);
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
const createNonDuplicatedServers = pipe(
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
hideModal,
);
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
importServersFromFile(target.files?.[0])
.then(pipe(createServers, onImport))
.then(setServersToCreate)
.then(() => {
// Reset input after processing file
(target as { value: string | null }).value = null;
})
.catch(onImportError);
useEffect(() => {
if (!serversToCreate) {
return;
}
const existingServers = Object.values(servers);
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
const hasDuplicatedServers = !!duplicatedServers.length;
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
hasDuplicatedServers && showModal();
}, [ serversToCreate ]);
return (
<>
<Button 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>.
</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 { DeleteServerButtonProps } from '../DeleteServerButton';
import { isServerWithId, SelectedServer, ServersMap } from '../data';
import NoMenuLayout from '../../common/NoMenuLayout';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import './ServerError.scss';
interface ServerErrorProps {

View File

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

View File

@@ -1,9 +1,8 @@
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 { ServerData } from '../data';
import { SimpleCard } from '../../utils/SimpleCard';
import './ServerForm.scss';
interface ServerFormProps {
onSubmit: (server: ServerData) => void;
@@ -11,9 +10,6 @@ interface ServerFormProps {
title?: ReactNode;
}
const FormGroup: FC<FormGroupContainerProps> = (props) =>
<FormGroupContainer {...props} labelClassName="create-server__label" />;
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
@@ -29,12 +25,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
return (
<form className="server-form" onSubmit={handleSubmit}>
<SimpleCard className="mb-3" title={title}>
<FormGroup value={name} onChange={setName}>Name</FormGroup>
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
<FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
</SimpleCard>
<div className="text-right">{children}</div>
<div className="text-end">{children}</div>
</form>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import csvjson from 'csvjson';
import Bottle, { Decorator } from 'bottlejs';
import Bottle from 'bottlejs';
import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal';
@@ -17,10 +17,10 @@ import { Overview } from '../Overview';
import { ManageServers } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow';
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
import ServersImporter from './ServersImporter';
import { ServersImporter } from './ServersImporter';
import ServersExporter from './ServersExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory(
'ManageServers',
@@ -30,7 +30,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'useStateFlagTimeout',
'ManageServersRow',
);
bottle.decorator('ManageServers', connect([ 'servers' ]));
bottle.decorator('ManageServers', withoutSelectedServer);
bottle.decorator('ManageServers', connect([ 'selectedServer', 'servers' ], [ 'resetSelectedServer' ]));
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.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer', 'resetSelectedServer' ]));
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter);
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));

View File

@@ -2,6 +2,8 @@ import { FormGroup, Input } from 'reactstrap';
import classNames from 'classnames';
import ToggleSwitch from '../utils/ToggleSwitch';
import { SimpleCard } from '../utils/SimpleCard';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings } from './reducers/settings';
interface RealTimeUpdatesProps {
@@ -12,22 +14,23 @@ interface RealTimeUpdatesProps {
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
const RealTimeUpdates = (
const RealTimeUpdatesSettings = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => (
<SimpleCard title="Real-time updates" className="h-100">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
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>.
</small>
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<LabeledFormGroup
noMargin
label="Real-time updates frequency (in minutes):"
labelClassName={classNames('form-label', { 'text-muted': !realTimeUpdates.enabled })}
>
<Input
type="number"
min={0}
@@ -37,17 +40,17 @@ const RealTimeUpdates = (
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
<FormText>
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
</FormText>
)}
</FormGroup>
</LabeledFormGroup>
</SimpleCard>
);
export default RealTimeUpdates;
export default RealTimeUpdatesSettings;

View File

@@ -1,29 +1,35 @@
import { FC, ReactNode } from 'react';
import { Row } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout';
import { Navigate, Routes, Route } from 'react-router-dom';
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) => (
<Row key={index}>
{child.map((subChild, subIndex) => (
<div key={subIndex} className="col-lg-6 mb-3">
{subChild}
</div>
))}
</Row>
))}
{items.map((child, index) => <div key={index} className="mb-3">{child}</div>)}
</>
);
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>
<SettingsSections
items={[
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
]}
/>
<NavPills className="mb-3">
<NavPillItem to="general">General</NavPillItem>
<NavPillItem to="short-urls">Short URLs</NavPillItem>
<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>
);

View File

@@ -3,11 +3,13 @@ import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
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 {
settings: Settings;
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
}
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>starting with</b> provided input.</>;
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
);
@@ -31,10 +33,10 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
>
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
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</small>
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup>
@@ -43,14 +45,13 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
>
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
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
</small>
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label>Tag suggestions search mode:</label>
<LabeledFormGroup noMargin label="Tag suggestions search mode:">
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
<DropdownItem
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
@@ -65,10 +66,8 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
{tagFilteringModeText('includes')}
</DropdownItem>
</DropdownBtn>
<small className="form-text text-muted">
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
</small>
</FormGroup>
<FormText>{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}</FormText>
</LabeledFormGroup>
</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 { SimpleCard } from '../utils/SimpleCard';
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 {
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">
<FormGroup className="mb-0">
<label>Default interval to load on visits sections:</label>
<LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<DateIntervalSelector
allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/>
</FormGroup>
</LabeledFormGroup>
</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 { Theme } from '../../utils/theme';
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 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
* 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 {
theme: Theme;
tagsMode?: TagsMode;
}
export interface VisitsSettings {
defaultInterval: DateInterval;
}
export interface TagsSettings {
defaultOrdering?: TagsOrder;
defaultMode?: TagsMode;
}
export interface ShortUrlsListSettings {
defaultOrdering?: ShortUrlsOrder;
}
export interface Settings {
realTimeUpdates: RealTimeUpdatesSettings;
shortUrlCreation?: ShortUrlCreationSettings;
shortUrlsList?: ShortUrlsListSettings;
ui?: UiSettings;
visits?: VisitsSettings;
tags?: TagsSettings;
}
const initialState: Settings = {
@@ -56,6 +73,9 @@ const initialState: Settings = {
visits: {
defaultInterval: 'last30Days',
},
shortUrlsList: {
defaultOrdering: DEFAULT_SHORT_URLS_ORDERING,
},
};
type SettingsAction = Action & Settings;
@@ -81,6 +101,11 @@ export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings):
shortUrlCreation: settings,
});
export const setShortUrlsListSettings = (settings: ShortUrlsListSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
shortUrlsList: settings,
});
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
ui: settings,
@@ -90,3 +115,8 @@ export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsActi
type: SET_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 RealTimeUpdates from '../RealTimeUpdates';
import RealTimeUpdatesSettings from '../RealTimeUpdatesSettings';
import Settings from '../Settings';
import {
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,
setShortUrlsListSettings,
setTagsSettings,
setUiSettings,
setVisitsSettings,
toggleRealTimeUpdates,
} from '../reducers/settings';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ShortUrlCreation } from '../ShortUrlCreation';
import { UserInterface } from '../UserInterface';
import { Visits } from '../Visits';
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
import { UserInterfaceSettings } from '../UserInterfaceSettings';
import { VisitsSettings } from '../VisitsSettings';
import { TagsSettings } from '../TagsSettings';
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// 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', connect(null, [ 'resetSelectedServer' ]));
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
bottle.decorator(
'RealTimeUpdates',
'RealTimeUpdatesSettings',
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
);
bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation);
bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
bottle.serviceFactory('UserInterface', () => UserInterface);
bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ]));
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ]));
bottle.serviceFactory('Visits', () => Visits);
bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ]));
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
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
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings);
bottle.serviceFactory('setUiSettings', () => setUiSettings);
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
};
export default provideServices;

View File

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

View File

@@ -1,5 +1,5 @@
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 { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
import classNames from 'classnames';
@@ -41,6 +41,7 @@ export const ShortUrlForm = (
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
const [ shortUrlData, setShortUrlData ] = useState(initialState);
const isEdit = mode === 'edit';
const isBasicMode = mode === 'create-basic';
const hadTitleOriginally = hasValue(initialState.title);
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlData(initialState);
@@ -66,8 +67,14 @@ export const ShortUrlForm = (
setShortUrlData(initialState);
}, [ initialState ]);
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
<FormGroup>
const renderOptionalInput = (
id: NonDateFields,
placeholder: string,
type: InputType = 'text',
props = {},
fromGroupProps = {},
) => (
<FormGroup {...fromGroupProps}>
<Input
id={id}
type={type}
@@ -79,15 +86,13 @@ export const ShortUrlForm = (
</FormGroup>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
{...props}
/>
</div>
<DateInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
{...props}
/>
);
const basicComponents = (
<>
@@ -101,10 +106,12 @@ export const ShortUrlForm = (
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
/>
</FormGroup>
<FormGroup>
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
</FormGroup>
<Row>
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
<div className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
</div>
</Row>
</>
);
@@ -118,8 +125,8 @@ export const ShortUrlForm = (
return (
<form className="short-url-form" onSubmit={submit}>
{mode === 'create-basic' && basicComponents}
{mode !== 'create-basic' && (
{isBasicMode && basicComponents}
{!isBasicMode && (
<>
<SimpleCard title="Basic options" className="mb-3">
{basicComponents}
@@ -145,12 +152,10 @@ export const ShortUrlForm = (
})}
</div>
</Row>
<FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</>
)}
</SimpleCard>
@@ -160,7 +165,9 @@ export const ShortUrlForm = (
<div className={limitAccessCardClasses}>
<SimpleCard title="Limit access to the short URL">
{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 })}
</SimpleCard>
</div>
@@ -180,7 +187,7 @@ export const ShortUrlForm = (
<p>
<Checkbox
inline
className="mr-2"
className="me-2"
checked={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 { FC, useEffect, useMemo, useState } from 'react';
import { RouteComponentProps } from 'react-router';
import { pipe } from 'ramda';
import { FC, useEffect, useState } from 'react';
import { Card } from 'reactstrap';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
import { useLocation, useParams } from 'react-router-dom';
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { ShlinkShortUrlsListParams } from '../api/types';
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
import { ShortUrlsTableProps } from './ShortUrlsTable';
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;
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void;
shortUrlsListParams: ShortUrlsListParams;
resetShortUrlParams: () => void;
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
settings: Settings;
}
type ShortUrlsOrder = Order<OrderableFields>;
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
listShortUrls,
resetShortUrlParams,
shortUrlsListParams,
match,
location,
history,
shortUrlsList,
selectedServer,
}: ShortUrlsListProps) => {
const ShortUrlsList = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
const { orderBy } = shortUrlsListParams;
const [ order, setOrder ] = useState<ShortUrlsOrder>({
field: orderBy && (head(keys(orderBy)) as OrderableFields),
dir: orderBy && head(values(orderBy)),
});
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
const { page } = useParams();
const location = useLocation();
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery();
const [ actualOrderBy, setActualOrderBy ] = useState(
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
);
const { pagination } = shortUrlsList?.shortUrls ?? {};
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
{ ...shortUrlsListParams, ...extraParams },
);
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
setOrder({ field, dir });
refreshList({ orderBy: field ? { [field]: dir } : undefined });
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir });
};
const orderByColumn = (field: OrderableFields) => () =>
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
const orderByColumn = (field: ShortUrlsOrderableFields) => () =>
handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir));
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
const addTag = pipe(
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
(tags) => toFirstPage({ tags }),
(newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
(updatedTags) => toFirstPage({ tags: updatedTags }),
);
useEffect(() => resetShortUrlParams, []);
useEffect(() => {
refreshList(
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
);
}, [ match.params.page, search, selectedTags, startDate, endDate ]);
listShortUrls({
page,
searchTerm: search,
tags,
startDate,
endDate,
orderBy: actualOrderBy,
tagsMode,
});
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
return (
<>
<div className="mb-3"><SearchBar /></div>
<div className="d-block d-lg-none mb-3">
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
</div>
<ShortUrlsFilteringBar
selectedServer={selectedServer}
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
className="mb-3"
/>
<Card body className="pb-1">
<ShortUrlsTable
selectedServer={selectedServer}

View File

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

View File

@@ -1,4 +1,5 @@
import { Nullable, OptionalString } from '../../utils/utils';
import { Order } from '../../utils/helpers/ordering';
export interface EditShortUrlData {
longUrl?: string;
@@ -50,3 +51,24 @@ export interface ShortUrlIdentifier {
shortCode: string;
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) {
return (
<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 :(" />
</Result>
);
@@ -42,7 +42,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
return (
<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>
<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 { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import classNames from 'classnames';
import { ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
@@ -56,10 +55,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
</ModalHeader>
<ModalBody>
<Row>
<FormGroup
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
>
<label className="mb-0">Size: {size}px</label>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<label>Size: {size}px</label>
<input
type="range"
className="form-control-range"
@@ -71,8 +68,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
/>
</FormGroup>
{capabilities.marginIsSupported && (
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
<label className="mb-0">Margin: {margin}px</label>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<label>Margin: {margin}px</label>
<input
type="range"
className="form-control-range"
@@ -106,7 +103,7 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
color="primary"
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
>
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button>
</div>
</ForServerVersion>

View File

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

View File

@@ -59,7 +59,7 @@ const ShortUrlsRow = (
<span className="indivisible short-urls-row__cell--relative">
<ExternalLink href={shortUrl.shortUrl} />
<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!
</span>
</span>
@@ -73,7 +73,7 @@ const ShortUrlsRow = (
</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
visitsCount={shortUrl.visitsCount}
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 { isEmpty } from 'ramda';
import { isEmpty, pipe } from 'ramda';
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<ShortUrlsQuery>) => void;
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
export interface ShortUrlListRouteParams {
page: string;
serverId: string;
}
interface ShortUrlsQuery {
tags?: string;
interface ShortUrlsQueryCommon {
search?: string;
startDate?: string;
endDate?: string;
tagsMode?: TagsFilteringMode;
}
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => {
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => {
const evolvedQuery = stringifyQuery({ ...query, ...extra });
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string;
tags?: string;
}
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}`;
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
navigate(`/server/${params.serverId}/list-short-urls/1${queryString}`);
};
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 { shortUrlMatches } from '../helpers';
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
@@ -7,7 +7,6 @@ import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { ShortUrlsListParams } from './shortUrlsListParams';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
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';
/* eslint-enable padding-line-between-statements */
export const ITEMS_IN_OVERVIEW_PAGE = 5;
export interface ShortUrlsList {
shortUrls?: ShlinkShortUrlsResponse;
loading: boolean;
@@ -25,7 +26,6 @@ export interface ShortUrlsList {
export interface ListShortUrlsAction extends Action<string> {
shortUrls: ShlinkShortUrlsResponse;
params: ShortUrlsListParams;
}
export type ListShortUrlsCombinedAction = (
@@ -77,10 +77,11 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
),
[CREATE_SHORT_URL]: pipe(
// 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(
[ 'shortUrls', 'data' ],
[ result, ...init(state.shortUrls.data) ],
[ result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1) ],
state,
),
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
@@ -109,8 +110,8 @@ export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
try {
const shortUrls = await listShortUrls(params);
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls, params });
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
} 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