Compare commits

...

127 Commits

Author SHA1 Message Date
Alejandro Celaya
e9fc2bb73a Merge pull request #166 from acelaya-forks/feature/fix-create-short-url
Ensured server version is properly parsed to avoid errors due to inva…
2019-10-18 17:48:02 +02:00
Alejandro Celaya
12f6b94ece Ensured server version is properly parsed to avoid errors due to invalid semver 2019-10-18 17:39:38 +02:00
Alejandro Celaya
d9a8243d36 Merge pull request #163 from acelaya-forks/feature/update-deps
Feature/update deps
2019-10-05 20:09:22 +02:00
Alejandro Celaya
232c54885e Updated node version in which builds are run 2019-10-05 19:58:27 +02:00
Alejandro Celaya
42c43f6c78 Added v2.2 to changelog 2019-10-05 19:54:10 +02:00
Alejandro Celaya
9d2494834c Fixed timing issue when navigating to another server 2019-10-05 19:51:50 +02:00
Alejandro Celaya
a7613435ea Fixed test throwing unhandled promise 2019-10-05 19:31:47 +02:00
Alejandro Celaya
c9df044e1a Updated docker image versions 2019-10-05 19:26:06 +02:00
Alejandro Celaya
5a37787042 Fixed warnings in tests 2019-10-05 19:13:57 +02:00
Alejandro Celaya
923cc3ba01 Updated dev dependencies 2019-10-05 19:08:50 +02:00
Alejandro Celaya
8fcf72f564 Updated production dependencies to latest versions 2019-10-05 18:50:49 +02:00
Alejandro Celaya
a7f7666ccd Merge pull request #162 from acelaya-forks/feature/domain
Feature/domain
2019-10-05 11:15:09 +02:00
Alejandro Celaya
c181948afe Updated changelog 2019-10-05 11:05:03 +02:00
Alejandro Celaya
ce9ecd7b93 Defined custom function to compare versions which defines the operator in the middle 2019-10-05 11:03:17 +02:00
Alejandro Celaya
354d19af1b Disabled domain component for Shlink versions not supporting it 2019-10-05 10:54:58 +02:00
Alejandro Celaya
6d996baf5d Added tests for new logics 2019-10-05 10:40:32 +02:00
Alejandro Celaya
4120d09220 Loaded version of selected server and created component to filter content based on that version 2019-10-05 10:20:33 +02:00
Alejandro Celaya
67a23bfe33 Added domain input to create short url form 2019-10-05 09:02:02 +02:00
Alejandro Celaya
08b710930d Merge pull request #161 from acelaya-forks/feature/further-issue-template-improvements
Solved inconsistencies in issue templates due to copy-pasting from ot…
2019-09-29 09:46:57 +02:00
Alejandro Celaya
7ec3b332ed Solved inconsistencies in issue templates due to copy-pasting from other project 2019-09-29 09:46:19 +02:00
Alejandro Celaya
722eb060f0 Merge pull request #160 from acelaya-forks/feature/improved-issue-templates
Added improved issue templates and funding config
2019-09-29 09:42:45 +02:00
Alejandro Celaya
ce740aed68 Added improved issue templates and funding config 2019-09-29 09:36:57 +02:00
Alejandro Celaya
09f582daa1 Merge pull request #159 from acelaya-forks/feature/fix-docker-image-reload
Added nginx congif which ensures client-side paths are served as the …
2019-09-22 12:02:18 +02:00
Alejandro Celaya
1b5f7b0d76 Added nginx congif which ensures client-side paths are served as the index.html 2019-09-22 11:55:21 +02:00
Alejandro Celaya
2c93e9a587 Merge pull request #158 from acelaya-forks/feature/improved-pagination
Feature/improved pagination
2019-09-22 11:44:54 +02:00
Alejandro Celaya
ab0976981b Fixed style files being excluded when finding what files to mutate 2019-09-22 11:35:52 +02:00
Alejandro Celaya
959ce42137 Updated changelog 2019-09-22 11:16:16 +02:00
Alejandro Celaya
1c25db9179 Created SimplePaginator test 2019-09-22 11:14:08 +02:00
Alejandro Celaya
810ddd7717 Added foldable pagination to SimplePaginator 2019-09-22 10:41:31 +02:00
Alejandro Celaya
7bbff114a4 Extracted paginator used in SortableBarGraph to its own component 2019-09-21 18:29:58 +02:00
Alejandro Celaya
99475fc311 Merge pull request #154 from Haocen/151
Fix an inaccurate variable name in test
2019-09-15 15:26:17 +02:00
Haocen Xu
df121eb294 Fix an inaccurate variable name in test 2019-09-15 09:14:00 -04:00
Alejandro Celaya
138194a149 Merge pull request #153 from Haocen/151
When no order is specified, the order by indicator(triangle) in column header should be Cleared
2019-09-15 09:30:11 +02:00
Haocen Xu
ab99213d8c When no order is specified, the order by indicator(triangle) in column header should be Cleared 2019-09-14 18:13:15 -04:00
Alejandro Celaya
2fe923678e Installed react-external-links 2019-08-29 17:47:18 +02:00
Alejandro Celaya
34f194c714 Merge pull request #143 from acelaya-forks/feature/docker-versions-bump
Feature/docker versions bump
2019-08-24 16:42:22 +02:00
Alejandro Celaya
2bef398d4c Added repository and license fields to package.json 2019-08-24 16:38:44 +02:00
Alejandro Celaya
404b5c45dd Updated changelog 2019-08-24 16:33:23 +02:00
Alejandro Celaya
f607ade508 Bumbed version of docker images 2019-08-24 16:31:54 +02:00
Alejandro Celaya
158ed84ec5 Updated changelog 2019-05-19 20:31:57 +02:00
Alejandro Celaya
7c22713d7d Merge pull request #139 from acelaya/feature/coverage-80
Created tags list reducer test
2019-05-19 20:30:13 +02:00
Alejandro Celaya
fb94077260 Created shortUrlTags reducer test 2019-05-19 13:22:16 +02:00
Alejandro Celaya
d3491869bd Created tags list reducer test 2019-05-19 12:54:19 +02:00
Alejandro Celaya
5cefadbf37 Added missing link in changelog to docs on new feature 2019-05-19 11:42:24 +02:00
Alejandro Celaya
95462b0c1d Merge pull request #137 from acelaya/feature/pre-configure-servers
Feature/pre configure servers
2019-04-28 18:01:46 +02:00
Alejandro Celaya
258330f985 Mentioned that pre-configured servers won't work on versions previous to 2.1.0 2019-04-28 17:53:35 +02:00
Alejandro Celaya
a09b661b51 Updated changelog 2019-04-28 17:42:20 +02:00
Alejandro Celaya
a1a0b935c7 Improved documentation mentioning how to pre-configure servers 2019-04-28 17:41:01 +02:00
Alejandro Celaya
4c11d9c6d5 Catched error when loading servers from a servers.json file 2019-04-28 13:07:55 +02:00
Alejandro Celaya
78c34a342d Added tests for new use cases 2019-04-28 12:40:50 +02:00
Alejandro Celaya
20820c47d4 Updated list servers action so that it tries to fetch servers from the servers.json file when no local servers are found 2019-04-28 12:07:09 +02:00
Alejandro Celaya
502c8a7e02 Echoing travis commit range 2019-04-22 19:13:02 +02:00
Alejandro Celaya
ce8a198acd Merge pull request #136 from acelaya/feature/stryker
Feature/stryker
2019-04-22 10:11:32 +02:00
Alejandro Celaya
32f171d861 Updated travis to run mutations on changed files only 2019-04-22 10:04:41 +02:00
Alejandro Celaya
b83c0e0aba Improved stryker config 2019-04-21 23:18:35 +02:00
Alejandro Celaya
831c0444d6 Added stryker to the project 2019-04-21 23:03:53 +02:00
Alejandro Celaya
e5ef2eb5c6 Merge pull request #135 from acelaya/feature/shlink-client-improvements
Removed duplicated code when building ShlinkApiClient
2019-04-21 11:37:14 +02:00
Alejandro Celaya
7b80d78dc5 Removed duplicated code when building ShlinkApiClient 2019-04-21 11:31:40 +02:00
Alejandro Celaya
48f7103205 Merge pull request #134 from acelaya/feature/jest-mocks
Feature/jest mocks
2019-04-19 13:02:28 +02:00
Alejandro Celaya
bc8de096be Updated changelog 2019-04-19 12:55:41 +02:00
Alejandro Celaya
ba3189fd46 Removed no longer needed constants 2019-04-19 12:54:56 +02:00
Alejandro Celaya
33d67cbe3d Simplified code making it easier to read 2019-04-19 12:52:55 +02:00
Alejandro Celaya
28ca54547e Removed remaining usages of sinon 2019-04-19 12:41:59 +02:00
Alejandro Celaya
f8de069567 First replacements of sinon mocks with jest mocks 2019-04-19 10:29:49 +02:00
Alejandro Celaya
2cd6e52e9c Merge pull request #133 from acelaya/feature/remove-yarn
Replaced yarn by npm
2019-04-14 22:06:59 +02:00
Alejandro Celaya
372d3f17cc Replaced yarn by npm 2019-04-14 21:58:10 +02:00
Alejandro Celaya
92d5b2eb3e Merge pull request #132 from acelaya/feature/issue-template
Created issue template with some reminders
2019-04-11 22:11:12 +02:00
Alejandro Celaya
6be55e30d9 Dockerignored .gihub folder 2019-04-11 22:03:53 +02:00
Alejandro Celaya
fd517ccbe2 Created issue template with some reminders 2019-04-11 22:01:11 +02:00
Alejandro Celaya
c2a34b4079 Merge pull request #127 from acelaya/feature/check-existing
Feature/check existing
2019-03-17 18:41:22 +01:00
Alejandro Celaya
ce0f036bef Created custom react hook that can be used to handle toggles 2019-03-17 18:35:47 +01:00
Alejandro Celaya
977e143b4e Fixed coding styles 2019-03-17 18:24:09 +01:00
Alejandro Celaya
d847ccf0f4 Updated changelog 2019-03-17 18:17:29 +01:00
Alejandro Celaya
7eeed76539 Created UseExistingIfFoundInfoIcon test 2019-03-17 18:15:44 +01:00
Alejandro Celaya
2e452993ff Created Checkbox test 2019-03-17 18:09:10 +01:00
Alejandro Celaya
f4dbd03c7e Added checkbox to control the findIfExists shlink flag 2019-03-17 17:48:05 +01:00
Alejandro Celaya
312c6cd550 Merge pull request #126 from acelaya/feature/redux-actions
Feature/redux actions
2019-03-17 10:31:13 +01:00
Alejandro Celaya
8d9e8565f0 Updated changelog 2019-03-17 10:23:17 +01:00
Alejandro Celaya
d1c10e4895 Removed test cases for the old default on reducers switch statements 2019-03-17 10:17:44 +01:00
Alejandro Celaya
232c059e4f Replaced usages of defaultState by initialState 2019-03-17 10:11:20 +01:00
Alejandro Celaya
5bb9d15e27 Refactored tagEdit reducer to take advantage of redux-actions 2019-03-17 10:07:28 +01:00
Alejandro Celaya
879034c9c6 Refactored tagDelete reducer to take advantage of redux-actions 2019-03-17 10:02:44 +01:00
Alejandro Celaya
740aacbbf1 Refactored tagsList reducer to take advantage of redux-actions 2019-03-17 09:59:26 +01:00
Alejandro Celaya
fcfab79bed Refactored shortUrlDetail reducer to take advantage of redux-actions 2019-03-17 09:38:37 +01:00
Alejandro Celaya
468e34aa3d Refactored shortUrlVisits reducer to take advantage of redux-actions 2019-03-17 09:36:07 +01:00
Alejandro Celaya
7ff7318089 Refactored shortUrlTags reducer to take advantage of redux-actions 2019-03-17 09:32:53 +01:00
Alejandro Celaya
4654bff737 Refactored shortUrlDeletion reducer to takle advantage of redux-actions 2019-03-17 09:27:01 +01:00
Alejandro Celaya
3075ccb4b9 Refactored shortUrlCreation reducer to takle advantage of redux-actions 2019-03-17 09:20:02 +01:00
Alejandro Celaya
4894ab9035 Refactored shortUrlsListParams reducer to takle advantage of redux-actions 2019-03-17 09:15:58 +01:00
Alejandro Celaya
4a09d99322 Refactored shortUrlsList to take advantage of redux-actions 2019-03-17 09:11:37 +01:00
Alejandro Celaya
51b5f6264d Refactored server reducer, removing duplicated code and taking advantage of redux-actions 2019-03-17 09:06:10 +01:00
Alejandro Celaya
724c804971 Installed redux-actions dependency and used it for selectedServer reducer 2019-03-17 08:49:24 +01:00
Alejandro Celaya
2ba86767fe Merge pull request #124 from acelaya/feature/paginated-charts
Feature/paginated charts
2019-03-16 09:11:01 +01:00
Alejandro Celaya
391424d8a1 Ensured bar charts are regenerated when their height changes 2019-03-16 09:02:40 +01:00
Alejandro Celaya
e0db6d5a57 Improved SortableBarGraph test 2019-03-10 17:55:02 +01:00
Alejandro Celaya
87dc24e8a2 Fixed and improved OpenMapModalBtn and ShortUrlVisit components tests 2019-03-10 13:05:20 +01:00
Alejandro Celaya
5233f5a07b Updated OpenMapModalBtn so that it allows showing only active cities 2019-03-10 12:09:54 +01:00
Alejandro Celaya
478ee59bb0 Updated cities chart so that map shows locations from current page when result set is paginated 2019-03-10 10:56:36 +01:00
Alejandro Celaya
b6f6b1ae9d Enabled stickiness on footer 2019-03-10 10:08:42 +01:00
Alejandro Celaya
1ad4290487 Applied some naming improvements 2019-03-10 09:54:40 +01:00
Alejandro Celaya
61480abd2e Updated charts to allow optional pagination 2019-03-10 08:28:14 +01:00
Alejandro Celaya
c094a27c97 Created PaginationDropdown component 2019-03-09 13:20:43 +01:00
Alejandro Celaya
83704ca4b5 Created rangeOf helper function which does a range + map 2019-03-09 12:19:33 +01:00
Alejandro Celaya
60576388c5 Merge pull request #122 from acelaya/feature/cancel-visits-load
Feature/cancel visits load
2019-03-08 19:56:45 +01:00
Alejandro Celaya
9f172c308c Ensured travis makes use of a working node version for builds 2019-03-08 19:50:47 +01:00
Alejandro Celaya
d7312d26f7 Added missing test for new action creator 2019-03-08 19:45:35 +01:00
Alejandro Celaya
4e6ef6ac53 Removed empty line 2019-03-08 19:43:27 +01:00
Alejandro Celaya
de563f9ebf Updated changelog 2019-03-08 19:42:07 +01:00
Alejandro Celaya
3982d77775 Ensured visits loading is cancelled when the visits page is unmounted 2019-03-08 19:40:43 +01:00
Alejandro Celaya
24bbbf6cb1 Merge pull request #121 from acelaya/feature/fix-empty-locations
Feature/fix empty locations
2019-03-05 14:33:33 +01:00
Alejandro Celaya
9ddd5de008 Updated changelog 2019-03-05 14:12:11 +01:00
Alejandro Celaya
87a4598391 Ensured maps modal btn is not rendered when the number of located cities is 0 2019-03-05 14:09:08 +01:00
Alejandro Celaya
701c143149 Updated ErrorHandler so that it logs errors in production 2019-03-05 14:04:52 +01:00
Alejandro Celaya
43097b93e5 Fixed docker build badge 2019-03-05 12:11:18 +01:00
Alejandro Celaya
e303a80683 Updated bootstrap to solve security issue 2019-03-04 21:05:30 +01:00
Alejandro Celaya
5defc20e9f Merge pull request #117 from acelaya/feature/error-handler
Feature/error handler
2019-03-04 20:55:48 +01:00
Alejandro Celaya
d75eff62e3 Updated changelog 2019-03-04 20:50:05 +01:00
Alejandro Celaya
ad9f0c00d0 Created ErrorHandler test 2019-03-04 20:49:18 +01:00
Alejandro Celaya
cd908fa358 Created ErrorHandler component 2019-03-04 20:41:02 +01:00
Alejandro Celaya
2bf79dbc80 Merge pull request #114 from acelaya/feature/lat-lang-error
Fixed crash when trying to load a map with just one location
2019-03-04 20:35:10 +01:00
Alejandro Celaya
4c729a405d Fixed crash when trying to load a map with just one location 2019-03-04 20:24:28 +01:00
Alejandro Celaya
28c9f9ac96 Merge pull request #112 from acelaya/feature/many-visits
Improved performance while calculating status
2019-03-04 19:37:00 +01:00
Alejandro Celaya
2820caf955 Updated changelog 2019-03-04 19:29:56 +01:00
Alejandro Celaya
ba5ea7407b Used native javascript reduce instead of ramda reduce 2019-03-04 19:28:24 +01:00
Alejandro Celaya
1bc406b0d9 Ensured requests when loading visits are made in parallel for big dataset 2019-03-04 19:21:46 +01:00
Alejandro Celaya
7e27ceb885 Ensured same timestamp is used when generating memoization ID after mounting the component 2019-03-04 18:19:50 +01:00
Alejandro Celaya
252edaa2ca Improved performance while calculating status by doing one iteration only and memoizing the result when possible 2019-03-04 18:14:45 +01:00
125 changed files with 21503 additions and 12017 deletions

View File

@@ -1,3 +1,4 @@
./.github
./build
./coverage
./dist

View File

@@ -26,6 +26,7 @@
"no-console": "warn",
"template-curly-spacing": ["error", "never"],
"no-warning-comments": "off",
"no-magic-numbers": "off",
"no-undefined": "off",
"indent": ["error", 2, {
"SwitchCase": 1

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ['https://acel.me/donate']

6
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,6 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

34
.github/ISSUE_TEMPLATE/Bug.md vendored Normal file
View File

@@ -0,0 +1,34 @@
---
name: Bug report
about: Something on shlink is broken or not working as documented?
labels: bug
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->
#### Shlink web client version
* Version: x.y.z
#### Summary
<!-- Provide a summary describing the problem you are experiencing. -->
#### Current behavior
<!-- How is it actually behaving (and it shouldn't)? -->
#### Expected behavior
<!-- How did you expected to behave? -->
#### How to reproduce
<!-- Provide steps to reproduce the bug. -->

View File

@@ -0,0 +1,18 @@
---
name: Feature request
about: Do you find shlink is missing some important feature that would make it more useful?
labels: feature
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->
#### Summary
<!-- Describe the new feature you would like to request. -->

View File

@@ -0,0 +1,22 @@
---
name: Question - Support
about: Do you have a problem setting up or using shlink?
labels: question
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->
#### Shlink web client version
* Version: x.y.z
#### Summary
<!-- Describe the issue you are facing here. -->

12
.gitignore vendored
View File

@@ -3,18 +3,14 @@
# testing
/coverage
/.stryker-tmp
/reports
# production
/build
# misc
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
docker-compose.override.yml
home
public/servers.json*

View File

@@ -1,5 +1,6 @@
build:
environment:
node: v10.4.1
node: v12.11.0
tools:
external_code_coverage: true
external_code_coverage:
timeout: 1200

View File

@@ -1,10 +1,9 @@
language: node_js
node_js:
- "stable"
- "12.11.0"
cache:
yarn: true
directories:
- node_modules
@@ -12,19 +11,24 @@ services:
- docker
install:
- yarn install
- npm ci
before_script:
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)' | paste -sd ",")
script:
- yarn lint
- yarn test:ci
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi # Test docker image build only when no tag is present
- npm run lint
- npm run test:ci
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
after_success:
- yarn ocular coverage/clover.xml
- node_modules/.bin/ocular coverage/clover.xml
# Before deploying, build dist file for current travis tag
before_deploy:
- yarn build ${TRAVIS_TAG#?}
- npm run build ${TRAVIS_TAG#?}
deploy:
provider: releases

View File

@@ -4,6 +4,153 @@ 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).
## 2.2.1 - 2019-10-18
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#165](https://github.com/shlinkio/shlink-web-client/issues/165) Fixed error thrown when opening "create" page while using a Shlink version which does not return a valid SemVer version (like `latest` docker image, or any development instance).
## 2.2.0 - 2019-10-05
#### Added
* [#144](https://github.com/shlinkio/shlink-web-client/issues/144) Added domain input to create domain page.
#### Changed
* [#140](https://github.com/shlinkio/shlink-web-client/issues/140) Updated project dependencies.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 2.1.1 - 2019-09-22
#### Added
* *Nothing*
#### Changed
* [#142](https://github.com/shlinkio/shlink-web-client/issues/142) Updated to newer versions of base docker images for dev and production.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#151](https://github.com/shlinkio/shlink-web-client/issues/151) Fixed "order by" indicator (caret) still indicate ASC on column header when no order is specified.
* [#157](https://github.com/shlinkio/shlink-web-client/issues/157) Fixed pagination control on graphs expanding too much when lots of pages need to be rendered.
* [#155](https://github.com/shlinkio/shlink-web-client/issues/155) Fixed client-side paths resolve to 404 when served from nginx in docker image instead of falling back to `index.html`.
## 2.1.0 - 2019-05-19
#### Added
* [#101](https://github.com/shlinkio/shlink-web-client/issues/101) Added checkbox to short URL creation form that allows to determine the value of the `findIfExists` flag introduced in Shlink v1.16.0.
* [#105](https://github.com/shlinkio/shlink-web-client/issues/105) Added support to pre-configure servers. See [how to pre-configure servers](README.md#pre-configuring-servers) to get more details on how to do it.
#### Changed
* [#125](https://github.com/shlinkio/shlink-web-client/issues/125) Refactored reducers to replace `switch` statements by `handleActions` from [redux-actions](https://github.com/redux-utilities/redux-actions).
* [#116](https://github.com/shlinkio/shlink-web-client/issues/116) Removed sinon in favor of jest mocks.
* [#72](https://github.com/shlinkio/shlink-web-client/issues/72) Increased code coverage up to 80%.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 2.0.3 - 2019-03-16
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#120](https://github.com/shlinkio/shlink-web-client/issues/120) Fixed crash when visits page is loaded and there are no visits with known cities.
* [#113](https://github.com/shlinkio/shlink-web-client/issues/113) Ensured visits loading is cancelled when the visits page is unmounted. Requests on flight will still finish.
* [#118](https://github.com/shlinkio/shlink-web-client/issues/118) Fixed chart crashing when trying to render lots of bars by adding pagination.
## 2.0.2 - 2019-03-04
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#103](https://github.com/shlinkio/shlink-web-client/issues/103) Fixed visits page getting freezed when loading large amounts of visits.
* [#111](https://github.com/shlinkio/shlink-web-client/issues/111) Fixed crash when trying to load a map modal with only one location.
* [#115](https://github.com/shlinkio/shlink-web-client/issues/115) Created `ErrorHandler` component which will prevent crashes in app to make it unusable.
## 2.0.1 - 2019-03-03
#### Added

View File

@@ -1,8 +1,9 @@
FROM node:10.15.2 as node
FROM node:12.11.0-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && yarn install && yarn build
RUN cd /shlink-web-client && npm install && npm run build
FROM nginx:1.15.9-alpine
FROM nginx:1.17.4-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=node /shlink-web-client/build /usr/share/nginx/html

View File

@@ -1,7 +1,7 @@
# shlink-web-client
[![Build Status](https://img.shields.io/travis/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink-web-client)
[![Docker build status](https://img.shields.io/docker/build/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![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)
@@ -14,9 +14,9 @@ A ReactJS-based progressive web application for [Shlink](https://shlink.io).
There are three ways in which you can use this application.
* The easiest way to use shlink-web-client is by just going to https://app.shlink.io.
* The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
The application runs 100% in the browser, so you can use that instance and access any shlink instance from it.
The application runs 100% in the browser, so you can safely access any shlink instance from there.
* Self hosting the application yourself.
@@ -24,28 +24,60 @@ There are three ways in which you can use this application.
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these simple steps](#serve-shlink-in-subpath).
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
* Use the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
* Using the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the image and do it.
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the `shlinkio/shlink-web-client` image and do it.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the assets on port 80.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
## Pre-configuring servers
The first time you access shlink-web-client from a browser, you will have to configure the list of shlink servers you want to manage, and they will be saved in the local storage.
Those servers can be exported and imported in other browsers, but if for some reason you need some servers to be there from the beginning, starting with shlink-web-client 2.1.0, you can provide a `servers.json` file in the project root folder (the same containing the `index.html`, `favicon.ico`, etc) with a structure like this:
```json
[
{
"name": "Main server",
"url": "https://doma.in",
"apiKey": "09c972b7-506b-49f1-a19a-d729e22e599c"
},
{
"name": "Local",
"url": "http://localhost:8080",
"apiKey": "580d0b42-4dea-419a-96bf-6c876b901451"
}
]
```
> The list can contain as many servers as you need.
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
## Serve project in subpath
Official distributable files have been build so that they are served from the root of a domain.
Official distributable files have been built so that they are served from the root of a domain.
If you need to host shlink-web-client yourself and serve it from a subpath, follow these steps:
* Download [node](https://nodejs.org/en/download/package-manager/) 10.4 or later (if you don't have it yet).
* Download [yarn](https://yarnpkg.com/en/docs/install) package manager.
* Download shlink-web-client source files for the version you want to build.
* Download shlink-web-client source code for the version you want to build.
* For example, if you want to build `v1.0.1`, use this link https://github.com/shlinkio/shlink-web-client/archive/v1.0.1.zip
* Replace the `v1.0.1` part in the link with the one of the version you want to build.
* Decompress the file and `cd` into the resulting folder.
* Install project dependencies by running `yarn install`.
* Open the `package.json` file in the root of the project, locate the `homepage` property and replace the value (which should be an empty string) by the path from which you want to serve shlink-web-client.
* For example: `"homepage": "/my-projects/shlink-web-client",`.
* Build the distributable contents by running `yarn build`.
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
* Build the project:
* For classic hosting:
* Download [node](https://nodejs.org/en/download/package-manager/) 10.15 or later.
* Install project dependencies by running `npm install`.
* Build the project by running `npm run build`.
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
* For docker image:
* Download [docker](https://docs.docker.com/install/).
* Build the docker image by running `docker build . -t shlink-web-client`.
* Once the command finishes, you will have an image with the name `shlink-web-client`.

17
config/docker/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80 default_server;
charset utf-8;
root /usr/share/nginx/html;
index index.html;
# When requesting static paths with extension, try them, and return a 404 if not found
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
try_files $uri $uri/ =404;
}
# When requesting a path without extension, try it, and return the index if not found
# This allows HTML5 history paths to be handled by the client application
location / {
try_files $uri $uri/ /index.html$is_args$args;
}
}

View File

@@ -6,3 +6,4 @@ services:
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
- ./home:/home/alejandro

View File

@@ -3,8 +3,8 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:10.15.0-alpine
command: /bin/sh -c "cd /home/shlink/www && yarn install && yarn start"
image: node:12.11.0-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www
ports:

18715
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,132 +1,144 @@
{
"name": "shlink-web-client-react",
"name": "shlink-web-client",
"description": "A React-based progressive web application for shlink",
"version": "1.0.0",
"version": "2.3.0",
"private": false,
"homepage": "",
"repository": "https://github.com/shlinkio/shlink-web-client",
"license": "MIT",
"scripts": {
"lint": "yarn lint:js && yarn lint:css",
"lint": "npm run lint:js && npm run lint:css",
"lint:js": "eslint src test scripts config",
"lint:js:fix": "yarn lint:js --fix",
"lint:js:fix": "npm run lint:js -- --fix",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:css:fix": "yarn lint:css --fix",
"lint:css:fix": "npm run lint:css -- --fix",
"start": "node scripts/start.js",
"serve:build": "yarn serve ./build",
"serve:build": "serve ./build",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom --colors",
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html"
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run",
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.6.3",
"@fortawesome/fontawesome-svg-core": "^1.2.0",
"@fortawesome/free-regular-svg-icons": "^5.6.3",
"@fortawesome/free-solid-svg-icons": "^5.6.3",
"@fortawesome/react-fontawesome": "^0.1.3",
"@fortawesome/fontawesome-free": "^5.11.2",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-regular-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"array-filter": "^1.0.0",
"array-map": "^0.0.0",
"array-reduce": "^0.0.0",
"axios": "^0.18.0",
"bootstrap": "~4.1.1",
"bottlejs": "^1.7.1",
"chart.js": "^2.7.2",
"axios": "^0.19.0",
"bootstrap": "^4.3.1",
"bottlejs": "^1.7.2",
"chart.js": "^2.8.0",
"classnames": "^2.2.6",
"compare-versions": "^3.5.1",
"csvjson": "^5.1.0",
"leaflet": "^1.4.0",
"moment": "^2.22.2",
"promise": "^8.0.1",
"prop-types": "^15.6.2",
"qs": "^6.5.2",
"ramda": "^0.25.0",
"react": "^16.7.0",
"react-autosuggest": "^9.4.0",
"react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",
"leaflet": "^1.5.1",
"moment": "^2.24.0",
"promise": "^8.0.3",
"prop-types": "^15.7.2",
"qs": "^6.9.0",
"ramda": "^0.26.1",
"react": "^16.10.2",
"react-autosuggest": "^9.4.3",
"react-chartjs-2": "^2.8.0",
"react-color": "^2.17.3",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "~1.5.0",
"react-dom": "^16.7.0",
"react-leaflet": "^2.1.4",
"react-moment": "^0.7.6",
"react-redux": "^5.0.7",
"react-router-dom": "^4.2.2",
"react-swipeable": "^4.3.0",
"react-dom": "^16.10.2",
"react-external-link": "^1.0.0",
"react-leaflet": "^2.4.0",
"react-moment": "^0.9.5",
"react-redux": "^7.1.1",
"react-router-dom": "^5.1.2",
"react-swipeable": "^5.4.0",
"react-tagsinput": "^3.19.0",
"reactstrap": "^6.0.1",
"redux": "^4.0.0",
"reactstrap": "^8.0.1",
"redux": "^4.0.4",
"redux-actions": "^2.6.5",
"redux-thunk": "^2.3.0",
"uuid": "^3.3.2"
"uuid": "^3.3.3"
},
"devDependencies": {
"@babel/core": "^7.1.6",
"@svgr/webpack": "^2.4.1",
"adm-zip": "0.4.11",
"autoprefixer": "^7.1.6",
"@babel/core": "^7.6.2",
"@stryker-mutator/core": "^2.1.0",
"@stryker-mutator/html-reporter": "^2.1.0",
"@stryker-mutator/javascript-mutator": "^2.1.0",
"@stryker-mutator/jest-runner": "^2.1.0",
"@svgr/webpack": "^4.3.3",
"adm-zip": "^0.4.13",
"autoprefixer": "^9.6.3",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"babel-plugin-named-asset-import": "^0.3.0",
"babel-preset-react-app": "^7.0.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"babel-loader": "^8.0.6",
"babel-plugin-named-asset-import": "^0.3.4",
"babel-preset-react-app": "^9.0.2",
"babel-runtime": "^6.26.0",
"bfj": "^6.1.1",
"case-sensitive-paths-webpack-plugin": "^2.1.2",
"chalk": "^2.4.1",
"css-loader": "^1.0.0",
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"bfj": "^7.0.1",
"case-sensitive-paths-webpack-plugin": "^2.2.0",
"chalk": "^2.4.2",
"css-loader": "^3.2.0",
"dotenv": "^8.1.0",
"dotenv-expand": "^5.1.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"eslint": "^5.11.1",
"eslint-config-adidas-babel": "^1.1.0",
"eslint-config-adidas-env": "^1.1.0",
"eslint-config-adidas-es6": "^1.2.0",
"eslint-config-adidas-react": "^1.1.1",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jest": "^21.22.0",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-react": "^7.11.1",
"file-loader": "^2.0.0",
"eslint-loader": "^3.0.2",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jest": "^22.17.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.16.0",
"file-loader": "^4.2.0",
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
"fs-extra": "^7.0.0",
"html-webpack-plugin": "^4.0.0-alpha.2",
"fs-extra": "^8.1.0",
"html-webpack-plugin": "^4.0.0-beta.8",
"identity-obj-proxy": "^3.0.0",
"jest": "^23.6.0",
"jest-pnp-resolver": "^1.0.1",
"jest-resolve": "^23.6.0",
"mini-css-extract-plugin": "^0.4.3",
"node-sass": "^4.9.0",
"jest": "^24.9.0",
"jest-each": "^24.9.0",
"jest-pnp-resolver": "^1.2.1",
"jest-resolve": "^24.9.0",
"mini-css-extract-plugin": "^0.8.0",
"node-sass": "^4.12.0",
"object-assign": "^4.1.1",
"ocular.js": "^0.1.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"pnp-webpack-plugin": "^1.1.0",
"postcss": "^7.0.7",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"pnp-webpack-plugin": "^1.5.0",
"postcss": "^7.0.18",
"postcss-flexbugs-fixes": "^4.1.0",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.3.1",
"postcss-preset-env": "^6.7.0",
"postcss-safe-parser": "^4.0.1",
"raf": "^3.4.0",
"react-app-polyfill": "^0.2.0",
"react-dev-utils": "^7.0.1",
"resolve": "^1.8.1",
"sass-loader": "^7.1.0",
"serve": "^10.0.0",
"sinon": "^6.1.5",
"style-loader": "^0.23.0",
"stylelint": "^9.9.0",
"raf": "^3.4.1",
"react-app-polyfill": "^1.0.4",
"react-dev-utils": "^9.1.0",
"resolve": "^1.12.0",
"sass-loader": "^8.0.0",
"serve": "^11.2.0",
"stryker-cli": "^1.0.0",
"style-loader": "^1.0.0",
"stylelint": "^9.10.1",
"stylelint-config-adidas": "^1.2.1",
"stylelint-config-adidas-bem": "^1.2.0",
"stylelint-config-recommended-scss": "^3.2.0",
"stylelint-scss": "^3.3.0",
"sw-precache-webpack-plugin": "^0.11.4",
"terser-webpack-plugin": "^1.1.0",
"url-loader": "^1.1.1",
"webpack": "^4.19.1",
"webpack-dev-server": "^3.1.14",
"webpack-manifest-plugin": "^2.0.4",
"whatwg-fetch": "^2.0.3",
"workbox-webpack-plugin": "^3.6.3"
"stylelint-config-recommended-scss": "^4.0.0",
"stylelint-scss": "^3.11.1",
"sw-precache-webpack-plugin": "^0.11.5",
"terser-webpack-plugin": "^2.1.2",
"url-loader": "^2.2.0",
"webpack": "^4.41.0",
"webpack-dev-server": "^3.8.2",
"webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.0.0",
"workbox-webpack-plugin": "^4.3.1"
},
"babel": {
"presets": [

View File

@@ -81,7 +81,7 @@ checkBrowsers(paths.appPath, isInteractive)
const urls = prepareUrls(protocol, HOST, port);
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler(webpack, config, appName, urls, useYarn);
const compiler = createCompiler({ webpack, config, appName, urls, useYarn });
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import './ErrorHandler.scss';
import { Button } from 'reactstrap';
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
};
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(e) {
if (process.env.NODE_ENV !== 'development') {
error(e);
}
}
render() {
if (this.state.hasError) {
return (
<div className="error-handler">
<h1>Oops! This is awkward :S</h1>
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br />
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
</div>
);
}
return this.props.children;
}
};
export default ErrorHandler;

View File

@@ -0,0 +1,9 @@
@import '../utils/mixins/vertical-align.scss';
.error-handler {
@include vertical-align();
padding: 20px;
text-align: center;
width: 100%;
}

View File

@@ -18,18 +18,20 @@ export default class Home extends React.Component {
}
render() {
const servers = values(this.props.servers);
const { servers: { list, loading } } = this.props;
const servers = values(list);
const hasServers = !isEmpty(servers);
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<h5 className="home__intro">
{hasServers && <span>Please, select a server.</span>}
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{!loading && hasServers && <span>Please, select a server.</span>}
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{loading && <span>Trying to load servers...</span>}
</h5>
{hasServers && (
{!loading && hasServers && (
<ListGroup className="home__servers-list">
{servers.map(({ name, id }) => (
<ListGroupItem

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import Swipeable from 'react-swipeable';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
@@ -20,9 +20,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
/* eslint react/no-deprecated: "off" */
componentWillMount() {
componentDidMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { range, max, min } from 'ramda';
import './SimplePaginator.scss';
const propTypes = {
pagesCount: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
setCurrentPage: PropTypes.func.isRequired,
};
export const ellipsis = '...';
const pagination = (currentPage, pageCount) => {
const delta = 2;
const pages = range(
max(delta, currentPage - delta),
min(pageCount - 1, currentPage + delta) + 1
);
if (currentPage - delta > delta) {
pages.unshift(ellipsis);
}
if (currentPage + delta < pageCount - 1) {
pages.push(ellipsis);
}
pages.unshift(1);
pages.push(pageCount);
return pages;
};
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
if (pagesCount < 2) {
return null;
}
const onClick = (page) => () => setCurrentPage(page);
return (
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
<PaginationItem disabled={currentPage <= 1}>
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
</PaginationItem>
{pagination(currentPage, pagesCount).map((page, index) => (
<PaginationItem
key={page !== ellipsis ? page : `${page}_${index}`}
active={page === currentPage}
disabled={page === ellipsis}
>
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
</PaginationItem>
</Pagination>
);
};
SimplePaginator.propTypes = propTypes;
export default SimplePaginator;

View File

@@ -0,0 +1,3 @@
.simple-paginator {
user-select: none;
}

View File

@@ -3,9 +3,11 @@ import MainHeader from '../MainHeader';
import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window);
bottle.constant('console', global.console);
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
bottle.decorator('ScrollToTop', withRouter);
@@ -29,6 +31,8 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};
export default provideServices;

View File

@@ -16,14 +16,16 @@ import './index.scss';
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
fixLeafletIcons();
const { App, ScrollToTop } = container;
const { App, ScrollToTop, ErrorHandler } = container;
render(
<Provider store={store}>
<BrowserRouter basename={homepage}>
<ScrollToTop>
<App />
</ScrollToTop>
<ErrorHandler>
<ScrollToTop>
<App />
</ScrollToTop>
</ErrorHandler>
</BrowserRouter>
</Provider>,
document.getElementById('root')

View File

@@ -51,3 +51,11 @@ body,
margin: 0 auto !important;
}
}
.pagination .page-link {
cursor: pointer;
}
.paddingless {
padding: 0;
}

View File

@@ -1,6 +1,5 @@
import { isEmpty, values } from 'ramda';
import React from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from './prop-types';
@@ -11,10 +10,24 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
renderServers = () => {
const { servers, selectedServer, selectServer } = this.props;
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
const servers = values(list);
const { push } = this.props.history;
const loadServer = (id) => {
selectServer(id)
.then(() => push(`/server/${id}/list-short-urls/1`))
.catch(() => {});
};
if (loading) {
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
}
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
@@ -22,16 +35,8 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
return (
<React.Fragment>
{values(servers).map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
active={selectedServer && selectedServer.id === id}
// FIXME This should be implicit
onClick={() => selectServer(id)}
>
{servers.map(({ name, id }) => (
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
{name}
</DropdownItem>
))}
@@ -46,18 +51,14 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
);
};
componentDidMount() {
this.props.listServers();
}
componentDidMount = this.props.listServers;
render() {
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
}
render = () => (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};
export default ServersDropdown;

View File

@@ -1,7 +1,5 @@
import React from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc, map } from 'ramda';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
@@ -22,10 +20,8 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
render() {
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const assocId = (server) => assoc('id', uuid(), server);
const onChange = ({ target }) =>
importServersFromFile(target.files[0])
.then(map(assocId))
.then(createServers)
.then(onImport)
.then(() => {

View File

@@ -1,32 +1,40 @@
import { createAction, handleActions } from 'redux-actions';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionIsValidSemVer } from '../../utils/utils';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
/* eslint-enable padding-line-between-statements, newline-after-var */
const defaultState = null;
export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
export default function reducer(state = defaultState, action) {
switch (action.type) {
case SELECT_SERVER:
return action.selectedServer;
case RESET_SELECTED_SERVER:
return defaultState;
default:
return state;
}
}
const initialState = null;
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = (serversService) => (serverId) => (dispatch) => {
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = serversService.findServerById(serverId);
const selectedServer = findServerById(serverId);
const { health } = await buildShlinkApiClient(selectedServer);
const version = await health()
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
.catch(() => MIN_FALLBACK_VERSION);
dispatch({
type: SELECT_SERVER,
selectedServer,
selectedServer: {
...selectedServer,
version,
},
});
};
export default handleActions({
[RESET_SELECTED_SERVER]: () => initialState,
[SELECT_SERVER]: (state, { selectedServer }) => selectedServer,
}, initialState);

View File

@@ -1,33 +1,51 @@
import { handleActions } from 'redux-actions';
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
import { v4 as uuid } from 'uuid';
import { homepage } from '../../../package.json';
/* eslint-disable padding-line-between-statements */
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
/* eslint-enable padding-line-between-statements */
export default function reducer(state = {}, action) {
switch (action.type) {
case FETCH_SERVERS:
return action.servers;
default:
return state;
const initialState = {
list: {},
loading: false,
};
const assocId = (server) => assoc('id', server.id || uuid(), server);
export default handleActions({
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
}, initialState);
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
dispatch({ type: FETCH_SERVERS_START });
const localList = listServers();
if (!isEmpty(localList)) {
dispatch({ type: FETCH_SERVERS, list: localList });
return;
}
}
export const listServers = (serversService) => () => ({
type: FETCH_SERVERS,
servers: serversService.listServers(),
});
// If local list is empty, try to fetch it remotely and calculate IDs for every server
const remoteList = await get(`${homepage}/servers.json`)
.then(prop('data'))
.then(map(assocId))
.catch(() => []);
export const createServer = (serversService, listServers) => (server) => {
serversService.createServer(server);
return listServers();
createServers(remoteList);
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
};
export const deleteServer = (serversService, listServers) => (server) => {
serversService.deleteServer(server);
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
return listServers();
};
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
export const createServers = (serversService, listServers) => (servers) => {
serversService.createServers(servers);
return listServers();
};
export const createServers = ({ createServers }, listServersAction) => pipe(
map(assocId),
createServers,
listServersAction
);

View File

@@ -1,9 +1,3 @@
import PropTypes from 'prop-types';
export const serversImporterType = PropTypes.shape({
importServersFromFile: PropTypes.func,
});
export default class ServersImporter {
constructor(csvjson) {
this.csvjson = csvjson;

View File

@@ -23,9 +23,6 @@ export default class ServersService {
this.storage.set(SERVERS_STORAGE_KEY, allServers);
};
deleteServer = (server) =>
this.storage.set(
SERVERS_STORAGE_KEY,
dissoc(server.id, this.listServers())
);
deleteServer = ({ id }) =>
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
}

View File

@@ -16,6 +16,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', withRouter);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
@@ -34,11 +35,11 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService');
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService');
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
};

View File

@@ -1,11 +1,16 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isNil, pipe, replace, trim } from 'ramda';
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react';
import { Collapse } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import ForVersion from '../utils/ForVersion';
import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
@@ -15,15 +20,18 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
selectedServer: serverType,
};
state = {
longUrl: '',
tags: [],
customSlug: undefined,
domain: undefined,
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
findIfExists: false,
moreOptionsVisible: false,
};
@@ -63,6 +71,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
assoc('validUntil', formatDate(this.state.validUntil))
)(this.state));
};
const currentServerVersion = this.props.selectedServer ? this.props.selectedServer.version : '';
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
return (
<div className="shlink-container">
@@ -86,19 +96,45 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('customSlug', 'Custom slug')}
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-6">
{renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
</div>
</div>
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
</div>
<div className="col-sm-3">
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div>
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={this.state.findIfExists}
onChange={(findIfExists) => this.setState({ findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForVersion>
</Collapse>
<div>
<button
type="button"
className="btn btn-outline-secondary create-short-url__btn"
className="btn btn-outline-secondary"
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
@@ -106,8 +142,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary create-short-url__btn float-right"
disabled={shortUrlCreationResult.loading}
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)}
>
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types';
import { range } from 'ramda';
import { rangeOf } from '../utils/utils';
const propTypes = {
serverId: PropTypes.string.isRequired,
@@ -20,7 +20,7 @@ export default function Paginator({ paginator = {}, serverId }) {
}
const renderPages = () =>
range(1, pagesCount + 1).map((pageNumber) => (
rangeOf(pagesCount, (pageNumber) => (
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
<PaginationLink
tag={Link}

View File

@@ -11,7 +11,7 @@ import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss';
const SORTABLE_FIELDS = {
export const SORTABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
longUrl: 'Long URL',
@@ -50,6 +50,10 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
return null;
}
if (!this.state.orderDir) {
return null;
}
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './UseExistingIfFoundInfoIcon.scss';
import { useToggle } from '../utils/utils';
const renderInfoModal = (isOpen, toggle) => (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
<ModalHeader toggle={toggle}>Info</ModalHeader>
<ModalBody>
<p>
When the&nbsp;
<b><i>&quot;Use existing URL if found&quot;</i></b>
&nbsp;checkbox is checked, the server will return an existing short URL if it matches provided params.
</p>
<p>
These are the checks performed by Shlink in order to determine if an existing short URL should be returned:
</p>
<ul>
<li>
When only the long URL is provided: The most recent match will be returned, or a new short URL will be created
if none is found.
</li>
<li>
When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match
the short URL using both the long URL and the slug, the long URL and the domain, or the three of them.
<br />
If the slug is being used by another long URL, an error will be returned.
</li>
<li>
When other params are provided: Same as in previous cases, but it will try to match existing short URLs with
all provided data. If any of them does not match, a new short URL will be created
</li>
</ul>
</ModalBody>
</Modal>
);
const UseExistingIfFoundInfoIcon = () => {
const [ isModalOpen, toggleModal ] = useToggle(false);
return (
<React.Fragment>
<span title="What does this mean?">
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
</span>
{renderInfoModal(isModalOpen, toggleModal)}
</React.Fragment>
);
};
export default UseExistingIfFoundInfoIcon;

View File

@@ -0,0 +1,7 @@
.use-existing-if-found-info-icon__modal-quote {
margin-bottom: 0;
padding: 10px 15px;
font-size: 17.5px;
border-left: 5px solid #eee;
background-color: #f9f9f9;
}

View File

@@ -35,7 +35,7 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() {
const { onCopyToClipboard, shortUrl, selectedServer: { id } } = this.props;
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen');
@@ -45,11 +45,11 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${id}/short-code/${shortUrl.shortCode}/visits`}>
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
</DropdownItem>

View File

@@ -1,11 +1,12 @@
import PropTypes from 'prop-types';
import { createAction, handleActions } from 'redux-actions';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const createShortUrlResultType = PropTypes.shape({
result: PropTypes.shape({
@@ -15,47 +16,26 @@ export const createShortUrlResultType = PropTypes.shape({
error: PropTypes.bool,
});
const defaultState = {
const initialState = {
result: null,
saving: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case CREATE_SHORT_URL_START:
return {
...state,
saving: true,
error: false,
};
case CREATE_SHORT_URL_ERROR:
return {
...state,
saving: false,
error: true,
};
case CREATE_SHORT_URL:
return {
result: action.result,
saving: false,
error: false,
};
case RESET_CREATE_SHORT_URL:
return defaultState;
default:
return state;
}
}
export default handleActions({
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
[CREATE_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[CREATE_SHORT_URL]: (state, { result }) => ({ result, saving: false, error: false }),
[RESET_CREATE_SHORT_URL]: () => initialState,
}, initialState);
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
dispatch({ type: CREATE_SHORT_URL_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { createShortUrl } = await buildShlinkApiClient(getState);
try {
const result = await shlinkApiClient.createShortUrl(data);
const result = await createShortUrl(data);
dispatch({ type: CREATE_SHORT_URL, result });
} catch (e) {
@@ -63,4 +43,4 @@ export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatc
}
};
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });
export const resetCreateShortUrl = createAction(RESET_CREATE_SHORT_URL);

View File

@@ -1,12 +1,13 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const shortUrlDeletionType = PropTypes.shape({
shortCode: PropTypes.string.isRequired,
@@ -18,47 +19,24 @@ export const shortUrlDeletionType = PropTypes.shape({
}).isRequired,
});
const defaultState = {
const initialState = {
shortCode: '',
loading: false,
error: false,
errorData: {},
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case DELETE_SHORT_URL_START:
return {
...state,
loading: true,
error: false,
};
case DELETE_SHORT_URL_ERROR:
return {
...state,
loading: false,
error: true,
errorData: action.errorData,
};
case DELETE_SHORT_URL:
return {
...state,
shortCode: action.shortCode,
loading: false,
error: false,
};
case RESET_DELETE_SHORT_URL:
return defaultState;
default:
return state;
}
}
export default handleActions({
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
[DELETE_SHORT_URL]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
[RESET_DELETE_SHORT_URL]: () => initialState,
}, initialState);
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START });
const { selectedServer } = getState();
const { deleteShortUrl } = buildShlinkApiClient(selectedServer);
const { deleteShortUrl } = await buildShlinkApiClient(getState);
try {
await deleteShortUrl(shortCode);
@@ -70,6 +48,6 @@ export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (di
}
};
export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL });
export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL);
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View File

@@ -1,13 +1,13 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const shortUrlTagsType = PropTypes.shape({
shortCode: PropTypes.string,
@@ -16,47 +16,26 @@ export const shortUrlTagsType = PropTypes.shape({
error: PropTypes.bool.isRequired,
});
const defaultState = {
const initialState = {
shortCode: null,
tags: [],
saving: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case EDIT_SHORT_URL_TAGS_START:
return {
...state,
saving: true,
error: false,
};
case EDIT_SHORT_URL_TAGS_ERROR:
return {
...state,
saving: false,
error: true,
};
case EDIT_SHORT_URL_TAGS:
return {
...pick([ 'shortCode', 'tags' ], action),
saving: false,
error: false,
};
case RESET_EDIT_SHORT_URL_TAGS:
return defaultState;
default:
return state;
}
}
export default handleActions({
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_TAGS]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
}, initialState);
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
try {
const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags);
const normalizedTags = await updateShortUrlTags(shortCode, tags);
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS });
} catch (e) {
@@ -66,7 +45,7 @@ export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => a
}
};
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS);
export const shortUrlTagsEdited = (shortCode, tags) => ({
tags,

View File

@@ -1,13 +1,14 @@
import { handleActions } from 'redux-actions';
import { assoc, assocPath, propEq, reject } from 'ramda';
import PropTypes from 'prop-types';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
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, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const shortUrlType = PropTypes.shape({
shortCode: PropTypes.string,
@@ -22,45 +23,29 @@ const initialState = {
error: false,
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case LIST_SHORT_URLS_START:
return { ...state, loading: true, error: false };
case LIST_SHORT_URLS:
return {
loading: false,
error: false,
shortUrls: action.shortUrls,
};
case LIST_SHORT_URLS_ERROR:
return {
loading: false,
error: true,
shortUrls: {},
};
case SHORT_URL_TAGS_EDITED:
const { data } = state.shortUrls;
export default handleActions({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }),
[SHORT_URL_TAGS_EDITED]: (state, action) => { // eslint-disable-line object-shorthand
const { data } = state.shortUrls;
return assocPath([ 'shortUrls', 'data' ], data.map((shortUrl) =>
shortUrl.shortCode === action.shortCode
? assoc('tags', action.tags, shortUrl)
: shortUrl), state);
case SHORT_URL_DELETED:
return assocPath(
[ 'shortUrls', 'data' ],
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
state,
);
default:
return state;
}
}
return assocPath([ 'shortUrls', 'data' ], data.map((shortUrl) =>
shortUrl.shortCode === action.shortCode
? assoc('tags', action.tags, shortUrl)
: shortUrl), state);
},
[SHORT_URL_DELETED]: (state, action) => assocPath(
[ 'shortUrls', 'data' ],
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
state,
),
}, initialState);
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START });
const { selectedServer = {} } = getState();
const { listShortUrls } = buildShlinkApiClient(selectedServer);
const { listShortUrls } = await buildShlinkApiClient(getState);
try {
const shortUrls = await listShortUrls(params);

View File

@@ -1,3 +1,4 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { LIST_SHORT_URLS } from './shortUrlsList';
@@ -9,17 +10,11 @@ export const shortUrlsListParamsType = PropTypes.shape({
searchTerm: PropTypes.string,
});
const defaultState = { page: '1' };
const initialState = { page: '1' };
export default function reducer(state = defaultState, action) {
switch (action.type) {
case LIST_SHORT_URLS:
return { ...state, ...action.params };
case RESET_SHORT_URL_PARAMS:
return defaultState;
default:
return state;
}
}
export default handleActions({
[LIST_SHORT_URLS]: (state, { params }) => ({ ...state, ...params }),
[RESET_SHORT_URL_PARAMS]: () => initialState,
}, initialState);
export const resetShortUrlParams = () => ({ type: RESET_SHORT_URL_PARAMS });
export const resetShortUrlParams = createAction(RESET_SHORT_URL_PARAMS);

View File

@@ -39,7 +39,7 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);

View File

@@ -1,52 +1,36 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const tagDeleteType = PropTypes.shape({
deleting: PropTypes.bool,
error: PropTypes.bool,
});
const defaultState = {
const initialState = {
deleting: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case DELETE_TAG_START:
return {
deleting: true,
error: false,
};
case DELETE_TAG_ERROR:
return {
deleting: false,
error: true,
};
case DELETE_TAG:
return {
deleting: false,
error: false,
};
default:
return state;
}
}
export default handleActions({
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
[DELETE_TAG_ERROR]: () => ({ deleting: false, error: true }),
[DELETE_TAG]: () => ({ deleting: false, error: false }),
}, initialState);
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
dispatch({ type: DELETE_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { deleteTags } = await buildShlinkApiClient(getState);
try {
await shlinkApiClient.deleteTags([ tag ]);
await deleteTags([ tag ]);
dispatch({ type: DELETE_TAG });
} catch (e) {
dispatch({ type: DELETE_TAG_ERROR });

View File

@@ -1,44 +1,30 @@
import { pick } from 'ramda';
import { handleActions } from 'redux-actions';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
const defaultState = {
const initialState = {
oldName: '',
newName: '',
editing: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case EDIT_TAG_START:
return {
...state,
editing: true,
error: false,
};
case EDIT_TAG_ERROR:
return {
...state,
editing: false,
error: true,
};
case EDIT_TAG:
return {
...pick([ 'oldName', 'newName' ], action),
editing: false,
error: false,
};
default:
return state;
}
}
export default handleActions({
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
[EDIT_TAG_ERROR]: (state) => ({ ...state, editing: false, error: true }),
[EDIT_TAG]: (state, action) => ({
...pick([ 'oldName', 'newName' ], action),
editing: false,
error: false,
}),
}, initialState);
export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newName, color) => async (
dispatch,
@@ -46,11 +32,10 @@ export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newNa
) => {
dispatch({ type: EDIT_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { editTag } = await buildShlinkApiClient(getState);
try {
await shlinkApiClient.editTag(oldName, newName);
await editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) {

View File

@@ -1,73 +1,47 @@
import { handleActions } from 'redux-actions';
import { isEmpty, reject } from 'ramda';
import { buildShlinkApiClientWithAxios as buildShlinkApiClient } from '../../utils/services/ShlinkApiClientBuilder';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
/* eslint-disable padding-line-between-statements, newline-after-var */
const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements */
const defaultState = {
const initialState = {
tags: [],
filteredTags: [],
loading: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case LIST_TAGS_START:
return {
...state,
loading: true,
error: false,
};
case LIST_TAGS_ERROR:
return {
...state,
loading: false,
error: true,
};
case LIST_TAGS:
return {
tags: action.tags,
filteredTags: action.tags,
loading: false,
error: false,
};
case TAG_DELETED:
return {
...state,
const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag;
const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags);
// FIXME This should be optimized somehow...
tags: reject((tag) => tag === action.tag, state.tags),
filteredTags: reject((tag) => tag === action.tag, state.filteredTags),
};
case TAG_EDITED:
const renameTag = (tag) => tag === action.oldName ? action.newName : tag;
export default handleActions({
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[LIST_TAGS]: (state, { tags }) => ({ tags, filteredTags: tags, loading: false, error: false }),
[TAG_DELETED]: (state, { tag }) => ({
...state,
tags: rejectTag(state.tags, tag),
filteredTags: rejectTag(state.filteredTags, tag),
}),
[TAG_EDITED]: (state, { oldName, newName }) => ({
...state,
tags: state.tags.map(renameTag(oldName, newName)).sort(),
filteredTags: state.filteredTags.map(renameTag(oldName, newName)).sort(),
}),
[FILTER_TAGS]: (state, { searchTerm }) => ({
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
}),
}, initialState);
return {
...state,
// FIXME This should be optimized somehow...
tags: state.tags.map(renameTag).sort(),
filteredTags: state.filteredTags.map(renameTag).sort(),
};
case FILTER_TAGS:
return {
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(action.searchTerm)),
};
default:
return state;
}
}
export const _listTags = (buildShlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList, selectedServer } = getState();
export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => {
const { tagsList } = getState();
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
return;
@@ -76,8 +50,8 @@ export const _listTags = (buildShlinkApiClient, force = false) => async (dispatc
dispatch({ type: LIST_TAGS_START });
try {
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const tags = await shlinkApiClient.listTags();
const { listTags } = await buildShlinkApiClient(getState);
const tags = await listTags();
dispatch({ tags, type: LIST_TAGS });
} catch (e) {
@@ -85,10 +59,6 @@ export const _listTags = (buildShlinkApiClient, force = false) => async (dispatc
}
};
export const listTags = () => _listTags(buildShlinkApiClient);
export const forceListTags = () => _listTags(buildShlinkApiClient, true);
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,
searchTerm,

View File

@@ -3,7 +3,7 @@ import TagCard from '../TagCard';
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
import EditTagModal from '../helpers/EditTagModal';
import TagsList from '../TagsList';
import { filterTags, forceListTags, listTags } from '../reducers/tagsList';
import { filterTags, listTags } from '../reducers/tagsList';
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
import { editTag, tagEdited } from '../reducers/tagEdit';
@@ -24,9 +24,11 @@ const provideServices = (bottle, connect) => {
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
// Actions
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);
bottle.factory('listTags', listTagsActionFactory(false));
bottle.factory('forceListTags', listTagsActionFactory(true));
bottle.serviceFactory('filterTags', () => filterTags);
bottle.serviceFactory('forceListTags', () => forceListTags);
bottle.serviceFactory('listTags', () => listTags);
bottle.serviceFactory('tagDeleted', () => tagDeleted);
bottle.serviceFactory('tagEdited', () => tagEdited);

27
src/utils/Checkbox.js Normal file
View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { v4 as uuid } from 'uuid';
const propTypes = {
checked: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
children: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
className: PropTypes.string,
};
const Checkbox = ({ checked, onChange, className, children }) => {
const id = uuid();
const onChecked = (e) => onChange(e.target.checked, e);
return (
<span className={classNames('custom-control custom-checkbox', className)} style={{ display: 'inline' }}>
<input type="checkbox" className="custom-control-input" id={id} checked={checked} onChange={onChecked} />
<label className="custom-control-label" htmlFor={id}>{children}</label>
</span>
);
};
Checkbox.propTypes = propTypes;
export default Checkbox;

View File

@@ -1,19 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
const propTypes = {
href: PropTypes.string.isRequired,
children: PropTypes.node,
};
export default function ExternalLink(props) {
const { href, children, ...rest } = props;
return (
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
{children || href}
</a>
);
}
ExternalLink.propTypes = propTypes;
export default ExternalLink;

19
src/utils/ForVersion.js Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { isEmpty } from 'ramda';
import { compareVersions } from './utils';
const propTypes = {
minVersion: PropTypes.string.isRequired,
currentServerVersion: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
const ForVersion = ({ minVersion, currentServerVersion, children }) =>
isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', minVersion)
? null
: <React.Fragment>{children}</React.Fragment>;
ForVersion.propTypes = propTypes;
export default ForVersion;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import * as PropTypes from 'prop-types';
const propTypes = {
toggleClassName: PropTypes.string,
ranges: PropTypes.arrayOf(PropTypes.number).isRequired,
value: PropTypes.number.isRequired,
setValue: PropTypes.func.isRequired,
};
const PaginationDropdown = ({ toggleClassName, ranges, value, setValue }) => (
<UncontrolledDropdown>
<DropdownToggle caret color="link" className={toggleClassName}>
Paginate
</DropdownToggle>
<DropdownMenu right>
{ranges.map((itemsPerPage) => (
<DropdownItem key={itemsPerPage} active={itemsPerPage === value} onClick={() => setValue(itemsPerPage)}>
<b>{itemsPerPage}</b> items per page
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem disabled={value === Infinity} onClick={() => setValue(Infinity)}>
<i>Clear pagination</i>
</DropdownItem>
</DropdownMenu>
</UncontrolledDropdown>
);
PaginationDropdown.propTypes = propTypes;
export default PaginationDropdown;

View File

@@ -20,4 +20,5 @@
@include vertical-align();
right: 15px;
cursor: pointer;
}

View File

@@ -33,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
<DropdownToggle
caret
color={isButton ? 'secondary' : 'link'}
className={classNames({ 'btn-block': isButton, 'btn-sm sorting-dropdown__paddingless': !isButton })}
className={classNames({ 'btn-block': isButton, 'btn-sm paddingless': !isButton })}
>
Order by
</DropdownToggle>

View File

@@ -10,7 +10,3 @@
margin: 3.5px 0 0;
float: right;
}
.sorting-dropdown__paddingless.sorting-dropdown__paddingless {
padding: 0;
}

View File

@@ -1,15 +1,11 @@
import { range } from 'ramda';
import PropTypes from 'prop-types';
import { rangeOf } from '../utils';
const HEX_COLOR_LENGTH = 6;
const { floor, random } = Math;
const letters = '0123456789ABCDEF';
const buildRandomColor = () =>
`#${
range(0, HEX_COLOR_LENGTH)
.map(() => letters[floor(random() * letters.length)])
.join('')
}`;
`#${rangeOf(HEX_COLOR_LENGTH, () => letters[floor(random() * letters.length)]).join('')}`;
const normalizeKey = (key) => key.toLowerCase().trim();
export default class ColorGenerator {

View File

@@ -2,12 +2,13 @@ import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export const buildShlinkBaseUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
this.axios = axios;
this._baseUrl = buildRestUrl(baseUrl);
this._baseUrl = buildShlinkBaseUrl(baseUrl);
this._apiKey = apiKey || '';
}
@@ -50,6 +51,8 @@ export default class ShlinkApiClient {
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }));
health = () => this._performRequest('/health', 'GET').then((resp) => resp.data);
_performRequest = async (url, method = 'GET', query = {}, body = {}) =>
await this.axios({
method,

View File

@@ -1,9 +1,22 @@
import * as axios from 'axios';
import { wait } from '../utils';
import ShlinkApiClient from './ShlinkApiClient';
const apiClients = {};
const buildShlinkApiClient = (axios) => ({ url, apiKey }) => {
const getSelectedServerFromState = async (getState) => {
const { selectedServer } = getState();
if (!selectedServer) {
return wait(250).then(() => getSelectedServerFromState(getState));
}
return selectedServer;
};
const buildShlinkApiClient = (axios) => async (getStateOrSelectedServer) => {
const { url, apiKey } = typeof getStateOrSelectedServer === 'function'
? await getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer;
const clientKey = `${url}_${apiKey}`;
if (!apiClients[clientKey]) {
@@ -14,5 +27,3 @@ const buildShlinkApiClient = (axios) => ({ url, apiKey }) => {
};
export default buildShlinkApiClient;
export const buildShlinkApiClientWithAxios = buildShlinkApiClient(axios);

View File

@@ -2,8 +2,13 @@ import L from 'leaflet';
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda';
import { useState } from 'react';
import { compare } from 'compare-versions';
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
const { ceil } = Math;
export const stateFlagTimeout = (setTimeout) => (
setState,
@@ -37,3 +42,29 @@ export const fixLeafletIcons = () => {
shadowUrl: markerShadow,
});
};
export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn);
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
export const useToggle = (initialValue = false) => {
const [ flag, setFlag ] = useState(initialValue);
return [ flag, () => setFlag(!flag) ];
};
export const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
export const compareVersions = (firstVersion, operator, secondVersion) => compare(
firstVersion,
secondVersion,
operator
);
export const versionIsValidSemVer = (version) => {
try {
return compareVersions(version, '=', version);
} catch (e) {
return false;
}
};

View File

@@ -1,18 +1,16 @@
import { Card, CardHeader, CardBody } from 'reactstrap';
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import PropTypes from 'prop-types';
import React from 'react';
import { keys, values } from 'ramda';
import './GraphCard.scss';
const propTypes = {
title: PropTypes.string,
children: PropTypes.node,
title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]),
footer: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
isBarChart: PropTypes.bool,
stats: PropTypes.object,
matchMedia: PropTypes.func,
};
const defaultProps = {
matchMedia: global.window ? global.window.matchMedia : () => {},
max: PropTypes.number,
};
const generateGraphData = (title, isBarChart, labels, data) => ({
@@ -36,62 +34,43 @@ const generateGraphData = (title, isBarChart, labels, data) => ({
],
});
const determineGraphAspectRatio = (barsCount, isBarChart, matchMedia) => {
const determineAspectRationModifier = () => {
switch (true) {
case matchMedia('(max-width: 1200px)').matches:
return 1.5; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 992px)').matches:
return 1.75; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 768px)').matches:
return 2; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 576px)').matches:
return 2.25; // eslint-disable-line no-magic-numbers
default:
return 1;
}
};
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
const MAX_BARS_WITHOUT_HEIGHT = 20;
const DEFAULT_ASPECT_RATION = 2;
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
return shouldCalculateAspectRatio
? MAX_BARS_WITHOUT_HEIGHT / determineAspectRationModifier() * DEFAULT_ASPECT_RATION / barsCount
: DEFAULT_ASPECT_RATION;
};
const renderGraph = (title, isBarChart, stats, matchMedia) => {
const renderGraph = (title, isBarChart, stats, max) => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const labels = keys(stats);
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(stats);
const aspectRatio = determineGraphAspectRatio(labels.length, isBarChart, matchMedia);
const options = {
aspectRatio,
legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart ? {
scales: isBarChart && {
xAxes: [
{
ticks: { beginAtZero: true },
ticks: { beginAtZero: true, max },
},
],
} : null,
},
tooltips: {
intersect: !isBarChart,
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
},
};
const graphData = generateGraphData(title, isBarChart, labels, data);
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null;
return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={null} />;
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
return <Component key={height} data={graphData} options={options} height={height} />;
};
const GraphCard = ({ title, children, isBarChart, stats, matchMedia }) => (
const GraphCard = ({ title, footer, isBarChart, stats, max }) => (
<Card className="mt-4">
<CardHeader className="graph-card__header">{children || title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max)}</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card>
);
GraphCard.propTypes = propTypes;
GraphCard.defaultProps = defaultProps;
export default GraphCard;

View File

@@ -0,0 +1,4 @@
.graph-card__footer--sticky {
position: sticky;
bottom: 0;
}

View File

@@ -8,20 +8,15 @@ import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage';
import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import { VisitsHeader } from './VisitsHeader';
import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
const ShortUrlVisits = ({
processOsStats,
processBrowserStats,
processCountriesStats,
processCitiesStats,
processReferrersStats,
processCitiesStatsForMap,
}) => class ShortUrlVisits extends React.Component {
const ShortUrlVisits = (
{ processStatsFromVisits },
OpenMapModalBtn
) => class ShortUrlVisits extends React.PureComponent {
static propTypes = {
match: PropTypes.shape({
params: PropTypes.object,
@@ -30,33 +25,47 @@ const ShortUrlVisits = ({
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func,
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props;
getShortUrlVisits(params.shortCode, mapObjIndexed(
const { shortCode } = params;
const dates = mapObjIndexed(
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state
));
);
const { startDate, endDate } = dates;
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
getShortUrlVisits(shortCode, dates);
};
componentDidMount() {
const { match: { params }, getShortUrlDetail } = this.props;
const { shortCode } = params;
this.timeWhenMounted = new Date().getTime();
this.loadVisits();
getShortUrlDetail(params.shortCode);
getShortUrlDetail(shortCode);
}
componentWillUnmount() {
this.props.cancelGetShortUrlVisits();
}
render() {
const { shortUrlVisits, shortUrlDetail } = this.props;
const renderVisitsContent = () => {
const { visits, loading, error } = shortUrlVisits;
const { visits, loading, loadingLarge, error } = shortUrlVisits;
if (loading) {
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> {message}</MutedMessage>;
}
if (error) {
@@ -71,17 +80,23 @@ const ShortUrlVisits = ({
return <MutedMessage>There are no visits matching current filter :(</MutedMessage>;
}
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
{ id: this.memoizationId, visits }
);
const mapLocations = values(citiesForMap);
return (
<div className="row">
<div className="col-xl-4 col-lg-6">
<GraphCard title="Operating systems" stats={processOsStats(visits)} />
<GraphCard title="Operating systems" stats={os} />
</div>
<div className="col-xl-4 col-lg-6">
<GraphCard title="Browsers" stats={processBrowserStats(visits)} />
<GraphCard title="Browsers" stats={browsers} />
</div>
<div className="col-xl-4">
<SortableBarGraph
stats={processReferrersStats(visits)}
stats={referrers}
withPagination={false}
title="Referrers"
sortingItems={{
name: 'Referrer name',
@@ -91,7 +106,7 @@ const ShortUrlVisits = ({
</div>
<div className="col-lg-6">
<SortableBarGraph
stats={processCountriesStats(visits)}
stats={countries}
title="Countries"
sortingItems={{
name: 'Country name',
@@ -101,16 +116,12 @@ const ShortUrlVisits = ({
</div>
<div className="col-lg-6">
<SortableBarGraph
stats={processCitiesStats(visits)}
stats={cities}
title="Cities"
extraHeaderContent={[
() => (
<OpenMapModalBtn
modalTitle="Cities"
locations={values(processCitiesStatsForMap(visits))}
/>
),
]}
extraHeaderContent={(activeCities) =>
mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
}
sortingItems={{
name: 'City name',
amount: 'Visits amount',
@@ -133,7 +144,7 @@ const ShortUrlVisits = ({
placeholderText="Since"
isClearable
maxDate={this.state.endDate}
onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())}
onChange={(date) => this.setState({ startDate: date }, this.loadVisits)}
/>
</div>
<div className="col-xl-3 col-lg-4 col-md-6">
@@ -143,7 +154,7 @@ const ShortUrlVisits = ({
placeholderText="Until"
isClearable
minDate={this.state.startDate}
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
onChange={(date) => this.setState({ endDate: date }, this.loadVisits)}
/>
</div>
</div>

View File

@@ -1,61 +1,111 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, toLower, toPairs, type } from 'ramda';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda';
import SortingDropdown from '../utils/SortingDropdown';
import PaginationDropdown from '../utils/PaginationDropdown';
import { rangeOf, roundTen } from '../utils/utils';
import SimplePaginator from '../common/SimplePaginator';
import GraphCard from './GraphCard';
const { max } = Math;
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component {
static propTypes = {
stats: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.arrayOf(PropTypes.func),
extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool,
};
state = {
orderField: undefined,
orderDir: undefined,
currentPage: 1,
itemsPerPage: Infinity,
};
render() {
const { stats, sortingItems, title, extraHeaderContent } = this.props;
const sortStats = () => {
if (!this.state.orderField) {
return stats;
}
determineStats(stats, sortingItems) {
const pairs = toPairs(stats);
const sortedPairs = !this.state.orderField ? pairs : sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
pairs
);
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
const sortedPairs = sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
toPairs(stats)
);
if (directionalPairs.length <= this.state.itemsPerPage) {
return { currentPageStats: fromPairs(directionalPairs) };
}
return fromPairs(this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs));
const pages = splitEvery(this.state.itemsPerPage, directionalPairs);
return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
pagination: this.renderPagination(pages.length),
max: roundTen(max(...directionalPairs.map(pickValueFromPair))),
};
}
return (
<GraphCard stats={sortStats()} isBarChart>
determineCurrentPagePairs(pages) {
const page = pages[this.state.currentPage - 1];
if (this.state.currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
}
renderPagination(pagesCount) {
const { currentPage } = this.state;
const setCurrentPage = (currentPage) => this.setState({ currentPage });
return <SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
}
render() {
const { stats, sortingItems, title, extraHeaderContent, withPagination = true } = this.props;
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
const activeCities = keys(currentPageStats);
const computeTitle = () => (
<React.Fragment>
{title}
<div className="float-right">
<SortingDropdown
isButton={false}
right
items={sortingItems}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
items={sortingItems}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir })}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir, currentPage: 1 })}
/>
</div>
{extraHeaderContent && extraHeaderContent.map((content, index) => (
<div key={index} className="float-right">
{content()}
{withPagination && keys(stats).length > 50 && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm paddingless mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={this.state.itemsPerPage}
setValue={(itemsPerPage) => this.setState({ itemsPerPage, currentPage: 1 })}
/>
</div>
))}
</GraphCard>
)}
{extraHeaderContent && (
<div className="float-right">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</React.Fragment>
);
return <GraphCard isBarChart title={computeTitle} stats={currentPageStats} footer={pagination} max={max} />;
}
}

View File

@@ -11,7 +11,7 @@ const propTypes = {
shortUrlVisits: shortUrlVisitsType.isRequired,
};
export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Modal, ModalBody } from 'reactstrap';
import { Map, TileLayer, Marker, Popup } from 'react-leaflet';
import { map, prop } from 'ramda';
import { prop } from 'ramda';
import * as PropTypes from 'prop-types';
import './MapModal.scss';
@@ -26,7 +26,21 @@ const OpenStreetMapTile = () => (
/>
);
const calculateMapBounds = map(prop('latLong'));
const calculateMapProps = (locations) => {
if (locations.length === 0) {
return {};
}
if (locations.length > 1) {
return { bounds: locations.map(prop('latLong')) };
}
// When there's only one location, an error is thrown if trying to calculate the bounds.
// When that happens, we use zoom and center as a workaround
const [{ latLong: center }] = locations;
return { zoom: '10', center };
};
const MapModal = ({ toggle, isOpen, title, locations }) => (
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
@@ -35,7 +49,7 @@ const MapModal = ({ toggle, isOpen, title, locations }) => (
{title}
<button type="button" className="close" onClick={toggle}>&times;</button>
</h3>
<Map bounds={calculateMapBounds(locations)}>
<Map {...calculateMapProps(locations)}>
<OpenStreetMapTile />
{locations.map(({ cityName, latLong, count }, index) => (
<Marker key={index} position={latLong}>

View File

@@ -1,32 +1,60 @@
import React from 'react';
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import { Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
import * as PropTypes from 'prop-types';
import MapModal from './MapModal';
import './OpenMapModalBtn.scss';
export default class OpenMapModalBtn extends React.Component {
static propTypes = {
modalTitle: PropTypes.string.isRequired,
locations: PropTypes.arrayOf(PropTypes.object),
};
const propTypes = {
modalTitle: PropTypes.string.isRequired,
locations: PropTypes.arrayOf(PropTypes.object),
activeCities: PropTypes.arrayOf(PropTypes.string),
};
state = { mapIsOpened: false };
const OpenMapModalBtn = (MapModal) => {
const OpenMapModalBtn = ({ modalTitle, locations = [], activeCities }) => {
const [ mapIsOpened, setMapIsOpened ] = useState(false);
const [ dropdownIsOpened, setDropdownIsOpened ] = useState(false);
const [ locationsToShow, setLocationsToShow ] = useState([]);
render() {
const { modalTitle, locations = [] } = this.props;
const toggleMap = () => this.setState(({ mapIsOpened }) => ({ mapIsOpened: !mapIsOpened }));
const buttonRef = React.createRef();
const filterLocations = (locations) => locations.filter(({ cityName }) => activeCities.includes(cityName));
const toggleMap = () => setMapIsOpened(!mapIsOpened);
const onClick = () => {
if (!activeCities) {
setLocationsToShow(locations);
setMapIsOpened(true);
return;
}
setDropdownIsOpened(true);
};
const openMapWithLocations = (filtered) => () => {
setLocationsToShow(filtered ? filterLocations(locations) : locations);
setMapIsOpened(true);
};
return (
<React.Fragment>
<button className="btn btn-link open-map-modal-btn__btn" ref={buttonRef} onClick={toggleMap}>
<button className="btn btn-link open-map-modal-btn__btn" ref={buttonRef} onClick={onClick}>
<FontAwesomeIcon icon={mapIcon} />
</button>
<UncontrolledTooltip placement="bottom" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
<MapModal toggle={toggleMap} isOpen={this.state.mapIsOpened} title={modalTitle} locations={locations} />
<UncontrolledTooltip placement="left" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
<Dropdown isOpen={dropdownIsOpened} toggle={() => setDropdownIsOpened(!dropdownIsOpened)} inNavbar>
<DropdownMenu right>
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
</DropdownMenu>
</Dropdown>
<MapModal toggle={toggleMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</React.Fragment>
);
}
}
};
OpenMapModalBtn.propTypes = propTypes;
return OpenMapModalBtn;
};
export default OpenMapModalBtn;

View File

@@ -1,11 +1,12 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { shortUrlType } from '../../short-urls/reducers/shortUrlsList';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR';
export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const shortUrlDetailType = PropTypes.shape({
shortUrl: shortUrlType,
@@ -19,38 +20,19 @@ const initialState = {
error: false,
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case GET_SHORT_URL_DETAIL_START:
return {
...state,
loading: true,
};
case GET_SHORT_URL_DETAIL_ERROR:
return {
...state,
loading: false,
error: true,
};
case GET_SHORT_URL_DETAIL:
return {
shortUrl: action.shortUrl,
loading: false,
error: false,
};
default:
return state;
}
}
export default handleActions({
[GET_SHORT_URL_DETAIL_START]: (state) => ({ ...state, loading: true }),
[GET_SHORT_URL_DETAIL_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
}, initialState);
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_DETAIL_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { getShortUrl } = await buildShlinkApiClient(getState);
try {
const shortUrl = await shlinkApiClient.getShortUrl(shortCode);
const shortUrl = await getShortUrl(shortCode);
dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) {

View File

@@ -1,10 +1,14 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { flatten, prop, range, splitEvery } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
/* eslint-enable padding-line-between-statements */
export const shortUrlVisitsType = PropTypes.shape({
visits: PropTypes.array,
@@ -15,38 +19,40 @@ export const shortUrlVisitsType = PropTypes.shape({
const initialState = {
visits: [],
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case GET_SHORT_URL_VISITS_START:
return {
...state,
loading: true,
};
case GET_SHORT_URL_VISITS_ERROR:
return {
...state,
loading: false,
error: true,
};
case GET_SHORT_URL_VISITS:
return {
visits: action.visits,
loading: false,
error: false,
};
default:
return state;
}
}
export default handleActions({
[GET_SHORT_URL_VISITS_START]: (state) => ({
...state,
loading: true,
loadingLarge: false,
cancelLoad: false,
}),
[GET_SHORT_URL_VISITS_ERROR]: (state) => ({
...state,
loading: false,
loadingLarge: false,
error: true,
cancelLoad: false,
}),
[GET_SHORT_URL_VISITS]: (state, { visits }) => ({
visits,
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
}),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
}, initialState);
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START });
const { selectedServer } = getState();
const { getShortUrlVisits } = buildShlinkApiClient(selectedServer);
const { getShortUrlVisits } = await buildShlinkApiClient(getState);
const itemsPerPage = 5000;
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
@@ -58,9 +64,42 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
return data;
}
return data.concat(await loadVisits(page + 1));
// If there are more pages, make requests in blocks of 4
const parallelRequestsCount = 4;
const parallelStartingPage = 2;
const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1);
const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange);
if (pagination.pagesCount - 1 > parallelRequestsCount) {
dispatch({ type: GET_SHORT_URL_VISITS_LARGE });
}
return data.concat(await loadPagesBlocks(pagesBlocks));
};
const loadPagesBlocks = async (pagesBlocks, index = 0) => {
const { shortUrlVisits: { cancelLoad } } = getState();
if (cancelLoad) {
return [];
}
const data = await loadVisitsInParallel(pagesBlocks[index]);
if (index < pagesBlocks.length - 1) {
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
}
return data;
};
const loadVisitsInParallel = (pages) =>
Promise.all(pages.map(
(page) =>
getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage })
.then(prop('data'))
)).then(flatten);
try {
const visits = await loadVisits();
@@ -69,3 +108,5 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
}
};
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);

View File

@@ -1,4 +1,4 @@
import { assoc, isNil, isEmpty, reduce } from 'ramda';
import { isNil, isEmpty, memoizeWith, prop } from 'ramda';
const osFromUserAgent = (userAgent) => {
const lowerUserAgent = userAgent.toLowerCase();
@@ -42,79 +42,69 @@ const extractDomain = (url) => {
return domain.split(':')[0];
};
export const processOsStats = (visits) =>
reduce(
(stats, { userAgent }) => {
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
return assoc(os, (stats[os] || 0) + 1, stats);
},
{},
visits,
);
export const processBrowserStats = (visits) =>
reduce(
(stats, { userAgent }) => {
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
return assoc(browser, (stats[browser] || 0) + 1, stats);
},
{},
visits,
);
export const processReferrersStats = (visits) =>
reduce(
(stats, visit) => {
const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer);
return assoc(domain, (stats[domain] || 0) + 1, stats);
},
{},
visits,
);
const visitLocationHasProperty = (visitLocation, propertyName) =>
!isNil(visitLocation)
&& !isNil(visitLocation[propertyName])
&& !isEmpty(visitLocation[propertyName]);
const buildLocationStatsProcessorByProperty = (propertyName) => (visits) =>
reduce(
(stats, { visitLocation }) => {
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName);
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown';
const updateOsStatsForVisit = (osStats, { userAgent }) => {
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
return assoc(value, (stats[value] || 0) + 1, stats);
osStats[os] = (osStats[os] || 0) + 1;
};
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
};
const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
const notHasDomain = isNil(referer) || isEmpty(referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(referer);
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
};
const updateLocationsStatsForVisit = (propertyName) => (stats, { visitLocation }) => {
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName);
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown';
stats[value] = (stats[value] || 0) + 1;
};
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('countryName');
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('cityName');
const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => {
if (!visitLocationHasProperty(visitLocation, 'cityName')) {
return;
}
const { cityName, latitude, longitude } = visitLocation;
const currentCity = citiesForMapStats[cityName] || {
cityName,
count: 0,
latLong: [ parseFloat(latitude), parseFloat(longitude) ],
};
currentCity.count++;
citiesForMapStats[cityName] = currentCity;
};
export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
visits.reduce(
(stats, visit) => {
// We mutate the original object because it has a big side effect when large data sets are processed
updateOsStatsForVisit(stats.os, visit);
updateBrowsersStatsForVisit(stats.browsers, visit);
updateReferrersStatsForVisit(stats.referrers, visit);
updateCountriesStatsForVisit(stats.countries, visit);
updateCitiesStatsForVisit(stats.cities, visit);
updateCitiesForMapForVisit(stats.citiesForMap, visit);
return stats;
},
{},
visits,
);
export const processCountriesStats = buildLocationStatsProcessorByProperty('countryName');
export const processCitiesStats = buildLocationStatsProcessorByProperty('cityName');
export const processCitiesStatsForMap = (visits) =>
reduce(
(stats, { visitLocation }) => {
if (!visitLocationHasProperty(visitLocation, 'cityName')) {
return stats;
}
const { cityName, latitude, longitude } = visitLocation;
const currentCity = stats[cityName] || {
cityName,
count: 0,
latLong: [ parseFloat(latitude), parseFloat(longitude) ],
};
currentCity.count++;
return assoc(cityName, currentCity, stats);
},
{},
visits,
);
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
));

View File

@@ -1,14 +1,18 @@
import ShortUrlVisits from '../ShortUrlVisits';
import { getShortUrlVisits } from '../reducers/shortUrlVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
import MapModal from '../helpers/MapModal';
import * as visitsParser from './VisitsParser';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser');
bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal');
bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail' ],
[ 'getShortUrlVisits', 'getShortUrlDetail' ]
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ]
));
// Services
@@ -17,6 +21,7 @@ const provideServices = (bottle, connect) => {
// Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
};
export default provideServices;

22
stryker.conf.js Normal file
View File

@@ -0,0 +1,22 @@
const jestConfig = require(`${__dirname}/jest.config.js`);
module.exports = (config) => config.set({
mutate: jestConfig.collectCoverageFrom,
mutator: 'javascript',
testRunner: 'jest',
reporters: [ 'progress', 'clear-text' ],
coverageAnalysis: 'off',
jest: {
projectType: 'custom',
config: jestConfig,
enableFindRelatedTests: true,
},
thresholds: {
high: 80,
low: 60,
break: null,
},
clearTextReporter: {
logTests: false,
},
});

View File

@@ -19,7 +19,6 @@ describe('<App />', () => {
it('renders app main routes', () => {
const routes = wrapper.find(Route);
const expectedRoutesCount = 4;
const expectedPaths = [
'/server/create',
'/',
@@ -27,7 +26,7 @@ describe('<App />', () => {
];
expect.assertions(expectedPaths.length + 1);
expect(routes).toHaveLength(expectedRoutesCount);
expect(routes).toHaveLength(4);
expectedPaths.forEach((path, index) => {
expect(routes.at(index).prop('path')).toEqual(path);
});

View File

@@ -16,9 +16,8 @@ describe('<AsideMenu />', () => {
it('contains links to different sections', () => {
const links = wrapped.find(NavLink);
const expectedLength = 3;
expect(links).toHaveLength(expectedLength);
expect(links).toHaveLength(3);
links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
});

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Button } from 'reactstrap';
import createErrorHandler from '../../src/common/ErrorHandler';
describe('<ErrorHandler />', () => {
const window = {
location: {
reload: jest.fn(),
},
};
const console = { error: jest.fn() };
let wrapper;
beforeEach(() => {
const ErrorHandler = createErrorHandler(window, console);
wrapper = shallow(<ErrorHandler children={<span>Foo</span>} />);
});
afterEach(() => wrapper.unmount());
it('renders children when no error has occurred', () => {
expect(wrapper.text()).toEqual('Foo');
expect(wrapper.find(Button)).toHaveLength(0);
});
it('renders error page when error has occurred', () => {
wrapper.setState({ hasError: true });
expect(wrapper.text()).toContain('Oops! This is awkward :S');
expect(wrapper.text()).toContain(
'It seems that something went wrong. Try refreshing the page or just click this button.'
);
expect(wrapper.find(Button)).toHaveLength(1);
});
});

View File

@@ -1,16 +1,13 @@
import { shallow } from 'enzyme';
import { values } from 'ramda';
import React from 'react';
import * as sinon from 'sinon';
import Home from '../../src/common/Home';
describe('<Home />', () => {
let wrapped;
const defaultProps = {
resetSelectedServer() {
return '';
},
servers: {},
resetSelectedServer: () => '',
servers: { loading: false, list: {} },
};
const createComponent = (props) => {
const actualProps = { ...defaultProps, ...props };
@@ -28,11 +25,11 @@ describe('<Home />', () => {
});
it('resets selected server when mounted', () => {
const resetSelectedServer = sinon.spy();
const resetSelectedServer = jest.fn();
expect(resetSelectedServer.called).toEqual(false);
expect(resetSelectedServer).not.toHaveBeenCalled();
createComponent({ resetSelectedServer });
expect(resetSelectedServer.called).toEqual(true);
expect(resetSelectedServer).toHaveBeenCalled();
});
it('shows link to create server when no servers exist', () => {
@@ -42,10 +39,22 @@ describe('<Home />', () => {
expect(wrapped.find('ListGroup')).toHaveLength(0);
});
it('shows message when loading servers', () => {
const wrapped = createComponent({ servers: { loading: true } });
const span = wrapped.find('span');
expect(span).toHaveLength(1);
expect(span.text()).toContain('Trying to load servers...');
expect(wrapped.find('ListGroup')).toHaveLength(0);
});
it('shows servers list when list of servers is not empty', () => {
const servers = {
1: { name: 'foo', id: '123' },
2: { name: 'bar', id: '456' },
loading: false,
list: {
1: { name: 'foo', id: '123' },
2: { name: 'bar', id: '456' },
},
};
const wrapped = createComponent({ servers });

View File

@@ -1,12 +1,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import createScrollToTop from '../../src/common/ScrollToTop';
describe('<ScrollToTop />', () => {
let wrapper;
const window = {
scrollTo: sinon.spy(),
scrollTo: jest.fn(),
};
beforeEach(() => {
@@ -17,13 +16,13 @@ describe('<ScrollToTop />', () => {
afterEach(() => {
wrapper.unmount();
window.scrollTo.resetHistory();
window.scrollTo.mockReset();
});
it('just renders children', () => expect(wrapper.text()).toEqual('Foobar'));
it('scrolls to top when location changes', () => {
wrapper.instance().componentDidUpdate({ location: { href: 'bar' } });
expect(window.scrollTo.calledOnce).toEqual(true);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import each from 'jest-each';
import { PaginationItem } from 'reactstrap';
import SimplePaginator, { ellipsis } from '../../src/common/SimplePaginator';
describe('<SimplePaginator />', () => {
let wrapper;
const createWrapper = (pagesCount, currentPage = 1) => {
wrapper = shallow(<SimplePaginator pagesCount={pagesCount} currentPage={currentPage} setCurrentPage={identity} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
each([ -3, -2, 0, 1 ]).it('renders empty when the amount of pages is smaller than 2', (pagesCount) => {
expect(createWrapper(pagesCount).text()).toEqual('');
});
describe('ellipsis are rendered where expected', () => {
const getItemsForPages = (pagesCount, currentPage) => {
const paginator = createWrapper(pagesCount, currentPage);
const items = paginator.find(PaginationItem);
const itemsWithEllipsis = items.filterWhere((item) => item.key() && item.key().includes(ellipsis));
return { items, itemsWithEllipsis };
};
it('renders first ellipsis', () => {
const { items, itemsWithEllipsis } = getItemsForPages(9, 7);
expect(items.at(2).html()).toContain(ellipsis);
expect(itemsWithEllipsis).toHaveLength(1);
});
it('renders last ellipsis', () => {
const { items, itemsWithEllipsis } = getItemsForPages(9, 2);
expect(items.at(items.length - 3).html()).toContain(ellipsis);
expect(itemsWithEllipsis).toHaveLength(1);
});
it('renders both ellipsis', () => {
const { items, itemsWithEllipsis } = getItemsForPages(20, 9);
expect(items.at(2).html()).toContain(ellipsis);
expect(items.at(items.length - 3).html()).toContain(ellipsis);
expect(itemsWithEllipsis).toHaveLength(2);
});
});
});

View File

@@ -1,20 +1,18 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import sinon from 'sinon';
import createServerConstruct from '../../src/servers/CreateServer';
describe('<CreateServer />', () => {
let wrapper;
const ImportServersBtn = () => '';
const createServerMock = sinon.fake();
const createServerMock = jest.fn();
const historyMock = {
push: sinon.fake(),
push: jest.fn(),
};
beforeEach(() => {
createServerMock.resetHistory();
historyMock.push.resetHistory();
createServerMock.mockReset();
const CreateServer = createServerConstruct(ImportServersBtn);
@@ -44,8 +42,8 @@ describe('<CreateServer />', () => {
return '';
} });
expect(createServerMock.callCount).toEqual(1);
expect(historyMock.push.callCount).toEqual(1);
expect(createServerMock).toHaveBeenCalledTimes(1);
expect(historyMock.push).toHaveBeenCalledTimes(1);
});
it('updates state when inputs are changed', () => {

View File

@@ -1,20 +1,19 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import DeleteServerModal from '../../src/servers/DeleteServerModal';
describe('<DeleteServerModal />', () => {
let wrapper;
const deleteServerMock = sinon.fake();
const historyMock = { push: sinon.fake() };
const toggleMock = sinon.fake();
const deleteServerMock = jest.fn();
const historyMock = { push: jest.fn() };
const toggleMock = jest.fn();
const serverName = 'the_server_name';
beforeEach(() => {
toggleMock.resetHistory();
deleteServerMock.resetHistory();
historyMock.push.resetHistory();
deleteServerMock.mockReset();
toggleMock.mockReset();
historyMock.push.mockReset();
wrapper = shallow(
<DeleteServerModal
@@ -48,9 +47,9 @@ describe('<DeleteServerModal />', () => {
cancelBtn.simulate('click');
expect(toggleMock.callCount).toEqual(1);
expect(deleteServerMock.callCount).toEqual(0);
expect(historyMock.push.callCount).toEqual(0);
expect(toggleMock).toHaveBeenCalledTimes(1);
expect(deleteServerMock).not.toHaveBeenCalled();
expect(historyMock.push).not.toHaveBeenCalled();
});
it('deletes server when clicking accept button', () => {
@@ -58,8 +57,8 @@ describe('<DeleteServerModal />', () => {
acceptBtn.simulate('click');
expect(toggleMock.callCount).toEqual(1);
expect(deleteServerMock.callCount).toEqual(1);
expect(historyMock.push.callCount).toEqual(1);
expect(toggleMock).toHaveBeenCalledTimes(1);
expect(deleteServerMock).toHaveBeenCalledTimes(1);
expect(historyMock.push).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,19 +8,25 @@ describe('<ServersDropdown />', () => {
let wrapped;
let ServersDropdown;
const servers = {
'1a': { name: 'foo', id: 1 },
'2b': { name: 'bar', id: 2 },
'3c': { name: 'baz', id: 3 },
list: {
'1a': { name: 'foo', id: 1 },
'2b': { name: 'bar', id: 2 },
'3c': { name: 'baz', id: 3 },
},
loading: false,
};
const history = {
push: jest.fn(),
};
beforeEach(() => {
ServersDropdown = serversDropdownCreator({});
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} />);
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} history={history} />);
});
afterEach(() => wrapped.unmount());
it('contains the list of servers', () =>
expect(wrapped.find(DropdownItem).filter('[to]')).toHaveLength(values(servers).length));
it('contains the list of servers, the divider and the export button', () =>
expect(wrapped.find(DropdownItem)).toHaveLength(values(servers.list).length + 2));
it('contains a toggle with proper title', () =>
expect(wrapped.find(DropdownToggle)).toHaveLength(1));
@@ -32,12 +38,25 @@ describe('<ServersDropdown />', () => {
expect(items.filter('.servers-dropdown__export-item')).toHaveLength(1);
});
it('contains a message when no servers exist yet', () => {
wrapped = shallow(<ServersDropdown servers={{}} listServers={identity} />);
it('shows a message when no servers exist yet', () => {
wrapped = shallow(
<ServersDropdown servers={{ loading: false, list: {} }} listServers={identity} history={history} />
);
const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1);
expect(item.prop('disabled')).toEqual(true);
expect(item.find('i').text()).toEqual('Add a server first...');
});
it('shows a message when loading', () => {
wrapped = shallow(
<ServersDropdown servers={{ loading: true, list: {} }} listServers={identity} history={history} />
);
const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1);
expect(item.prop('disabled')).toEqual(true);
expect(item.find('i').text()).toEqual('Trying to load servers...');
});
});

View File

@@ -1,25 +1,24 @@
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import { UncontrolledTooltip } from 'reactstrap';
import importServersBtnConstruct from '../../../src/servers/helpers/ImportServersBtn';
describe('<ImportServersBtn />', () => {
let wrapper;
const onImportMock = sinon.fake();
const createServersMock = sinon.fake();
const onImportMock = jest.fn();
const createServersMock = jest.fn();
const serversImporterMock = {
importServersFromFile: sinon.fake.returns(Promise.resolve([])),
importServersFromFile: jest.fn().mockResolvedValue([]),
};
const fileRef = {
current: { click: sinon.fake() },
current: { click: jest.fn() },
};
beforeEach(() => {
onImportMock.resetHistory();
createServersMock.resetHistory();
serversImporterMock.importServersFromFile.resetHistory();
fileRef.current.click.resetHistory();
onImportMock.mockReset();
createServersMock.mockReset();
serversImporterMock.importServersFromFile.mockClear();
fileRef.current.click.mockReset();
const ImportServersBtn = importServersBtnConstruct(serversImporterMock);
@@ -40,7 +39,7 @@ describe('<ImportServersBtn />', () => {
btn.simulate('click');
expect(fileRef.current.click.callCount).toEqual(1);
expect(fileRef.current.click).toHaveBeenCalledTimes(1);
});
it('imports servers when file input changes', (done) => {
@@ -49,9 +48,9 @@ describe('<ImportServersBtn />', () => {
file.simulate('change', { target: { files: [ '' ] } });
setImmediate(() => {
expect(serversImporterMock.importServersFromFile.callCount).toEqual(1);
expect(createServersMock.callCount).toEqual(1);
expect(onImportMock.callCount).toEqual(1);
expect(serversImporterMock.importServersFromFile).toHaveBeenCalledTimes(1);
expect(createServersMock).toHaveBeenCalledTimes(1);
expect(onImportMock).toHaveBeenCalledTimes(1);
done();
});
});

View File

@@ -1,17 +1,16 @@
import * as sinon from 'sinon';
import each from 'jest-each';
import reducer, {
selectServer,
resetSelectedServer,
RESET_SELECTED_SERVER,
SELECT_SERVER,
MAX_FALLBACK_VERSION,
MIN_FALLBACK_VERSION,
} from '../../../src/servers/reducers/selectedServer';
import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams';
describe('selectedServerReducer', () => {
describe('reducer', () => {
it('returns default when action is not handled', () =>
expect(reducer(null, { type: 'unknown' })).toEqual(null));
it('returns default when action is RESET_SELECTED_SERVER', () =>
expect(reducer(null, { type: RESET_SELECTED_SERVER })).toEqual(null));
@@ -33,32 +32,55 @@ describe('selectedServerReducer', () => {
const selectedServer = {
id: serverId,
};
const version = '1.19.0';
const ServersServiceMock = {
findServerById: sinon.fake.returns(selectedServer),
findServerById: jest.fn(() => selectedServer),
};
const apiClientMock = {
health: jest.fn(),
};
const buildApiClient = jest.fn().mockResolvedValue(apiClientMock);
const dispatch = jest.fn();
afterEach(() => {
ServersServiceMock.findServerById.resetHistory();
afterEach(jest.clearAllMocks);
each([
[ version, version ],
[ 'latest', MAX_FALLBACK_VERSION ],
[ '%invalid_semver%', MIN_FALLBACK_VERSION ],
]).it('dispatches proper actions', async (serverVersion, expectedVersion) => {
const expectedSelectedServer = {
...selectedServer,
version: expectedVersion,
};
apiClientMock.health.mockResolvedValue({ version: serverVersion });
await selectServer(ServersServiceMock, buildApiClient)(serverId)(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SHORT_URL_PARAMS });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
});
it('dispatches proper actions', () => {
const dispatch = sinon.spy();
const expectedDispatchCalls = 2;
it('invokes dependencies', async () => {
await selectServer(ServersServiceMock, buildApiClient)(serverId)(() => {});
selectServer(ServersServiceMock)(serverId)(dispatch);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.firstCall.calledWith({ type: RESET_SHORT_URL_PARAMS })).toEqual(true);
expect(dispatch.secondCall.calledWith({
type: SELECT_SERVER,
selectedServer,
})).toEqual(true);
expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1);
expect(buildApiClient).toHaveBeenCalledTimes(1);
});
it('invokes dependencies', () => {
selectServer(ServersServiceMock)(serverId)(() => {});
it('falls back to min version when health endpoint fails', async () => {
const expectedSelectedServer = {
...selectedServer,
version: MIN_FALLBACK_VERSION,
};
expect(ServersServiceMock.findServerById.callCount).toEqual(1);
apiClientMock.health.mockRejectedValue({});
await selectServer(ServersServiceMock, buildApiClient)(serverId)(dispatch);
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
});
});
});

View File

@@ -1,51 +1,73 @@
import * as sinon from 'sinon';
import { values } from 'ramda';
import reducer, {
createServer,
deleteServer,
listServers,
createServers,
FETCH_SERVERS,
FETCH_SERVERS, FETCH_SERVERS_START,
} from '../../../src/servers/reducers/server';
describe('serverReducer', () => {
const servers = {
const list = {
abc123: { id: 'abc123' },
def456: { id: 'def456' },
};
const expectedFetchServersResult = { type: FETCH_SERVERS, servers };
const expectedFetchServersResult = { type: FETCH_SERVERS, list };
const ServersServiceMock = {
listServers: sinon.fake.returns(servers),
createServer: sinon.fake(),
deleteServer: sinon.fake(),
createServers: sinon.fake(),
listServers: jest.fn(() => list),
createServer: jest.fn(),
deleteServer: jest.fn(),
createServers: jest.fn(),
};
describe('reducer', () => {
it('returns servers when action is FETCH_SERVERS', () =>
expect(reducer({}, { type: FETCH_SERVERS, servers })).toEqual(servers));
it('returns default when action is unknown', () =>
expect(reducer({}, { type: 'unknown' })).toEqual({}));
expect(reducer({}, { type: FETCH_SERVERS, list })).toEqual({ loading: false, list }));
});
describe('action creators', () => {
beforeEach(() => {
ServersServiceMock.listServers.resetHistory();
ServersServiceMock.createServer.resetHistory();
ServersServiceMock.deleteServer.resetHistory();
ServersServiceMock.createServers.resetHistory();
ServersServiceMock.listServers.mockClear();
ServersServiceMock.createServer.mockReset();
ServersServiceMock.deleteServer.mockReset();
ServersServiceMock.createServers.mockReset();
});
describe('listServers', () => {
it('fetches servers and returns them as part of the action', () => {
const result = listServers(ServersServiceMock)();
const axios = { get: jest.fn().mockResolvedValue({ data: [] }) };
const dispatch = jest.fn();
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.calledOnce).toEqual(true);
expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.deleteServer.called).toEqual(false);
expect(ServersServiceMock.createServers.called).toEqual(false);
beforeEach(() => {
axios.get.mockClear();
dispatch.mockReset();
});
it('fetches servers from local storage when found', async () => {
await listServers(ServersServiceMock, axios)()(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: FETCH_SERVERS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, expectedFetchServersResult);
expect(ServersServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
expect(axios.get).not.toHaveBeenCalled();
});
it('tries to fetch servers from remote when not found locally', async () => {
const NoListServersServiceMock = { ...ServersServiceMock, listServers: jest.fn(() => ({})) };
await listServers(NoListServersServiceMock, axios)()(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: FETCH_SERVERS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: {} });
expect(NoListServersServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(NoListServersServiceMock.createServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.createServers).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledTimes(1);
});
});
@@ -55,11 +77,11 @@ describe('serverReducer', () => {
const result = createServer(ServersServiceMock, () => expectedFetchServersResult)(serverToCreate);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.createServer.calledOnce).toEqual(true);
expect(ServersServiceMock.createServer.firstCall.calledWith(serverToCreate)).toEqual(true);
expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.deleteServer.called).toEqual(false);
expect(ServersServiceMock.createServers.called).toEqual(false);
expect(ServersServiceMock.createServer).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.createServer).toHaveBeenCalledWith(serverToCreate);
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
});
});
@@ -69,25 +91,25 @@ describe('serverReducer', () => {
const result = deleteServer(ServersServiceMock, () => expectedFetchServersResult)(serverToDelete);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.createServers.called).toEqual(false);
expect(ServersServiceMock.deleteServer.calledOnce).toEqual(true);
expect(ServersServiceMock.deleteServer.firstCall.calledWith(serverToDelete)).toEqual(true);
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.deleteServer).toHaveBeenCalledWith(serverToDelete);
});
});
describe('createServer', () => {
it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(servers);
const serversToCreate = values(list);
const result = createServers(ServersServiceMock, () => expectedFetchServersResult)(serversToCreate);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.createServers.calledOnce).toEqual(true);
expect(ServersServiceMock.createServers.firstCall.calledWith(serversToCreate)).toEqual(true);
expect(ServersServiceMock.deleteServer.called).toEqual(false);
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.createServers).toHaveBeenCalledWith(serversToCreate);
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,26 +1,25 @@
import sinon from 'sinon';
import ServersExporter from '../../../src/servers/services/ServersExporter';
describe('ServersExporter', () => {
const createLinkMock = () => ({
setAttribute: sinon.fake(),
click: sinon.fake(),
setAttribute: jest.fn(),
click: jest.fn(),
style: {},
});
const createWindowMock = (isIe10 = true) => ({
navigator: {
msSaveBlob: isIe10 ? sinon.fake() : undefined,
msSaveBlob: isIe10 ? jest.fn() : undefined,
},
document: {
createElement: sinon.fake.returns(createLinkMock()),
createElement: jest.fn(() => createLinkMock()),
body: {
appendChild: sinon.fake(),
removeChild: sinon.fake(),
appendChild: jest.fn(),
removeChild: jest.fn(),
},
},
});
const serversServiceMock = {
listServers: sinon.fake.returns({
listServers: jest.fn(() => ({
abc123: {
id: 'abc123',
name: 'foo',
@@ -29,10 +28,16 @@ describe('ServersExporter', () => {
id: 'def456',
name: 'bar',
},
}),
})),
};
const createCsvjsonMock = (throwError = false) => ({
toCSV: throwError ? sinon.fake.throws('') : sinon.fake.returns(''),
toCSV: jest.fn(() => {
if (throwError) {
throw new Error('');
}
return '';
}),
});
describe('exportServers', () => {
@@ -40,10 +45,10 @@ describe('ServersExporter', () => {
beforeEach(() => {
originalConsole = global.console;
global.console = { error: sinon.fake() };
global.console = { error: jest.fn() };
global.Blob = class Blob {};
global.URL = { createObjectURL: () => '' };
serversServiceMock.listServers.resetHistory();
serversServiceMock.listServers.mockReset();
});
afterEach(() => {
global.console = originalConsole;
@@ -59,8 +64,8 @@ describe('ServersExporter', () => {
exporter.exportServers();
expect(global.console.error.callCount).toEqual(1);
expect(csvjsonMock.toCSV.callCount).toEqual(1);
expect(global.console.error).toHaveBeenCalledTimes(1);
expect(csvjsonMock.toCSV).toHaveBeenCalledTimes(1);
});
it('makes use of msSaveBlob API when available', () => {
@@ -73,9 +78,9 @@ describe('ServersExporter', () => {
exporter.exportServers();
expect(serversServiceMock.listServers.callCount).toEqual(1);
expect(windowMock.navigator.msSaveBlob.callCount).toEqual(1);
expect(windowMock.document.createElement.callCount).toEqual(0);
expect(serversServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(windowMock.navigator.msSaveBlob).toHaveBeenCalledTimes(1);
expect(windowMock.document.createElement).not.toHaveBeenCalled();
});
it('makes use of download link API when available', () => {
@@ -88,10 +93,10 @@ describe('ServersExporter', () => {
exporter.exportServers();
expect(serversServiceMock.listServers.callCount).toEqual(1);
expect(windowMock.document.createElement.callCount).toEqual(1);
expect(windowMock.document.body.appendChild.callCount).toEqual(1);
expect(windowMock.document.body.removeChild.callCount).toEqual(1);
expect(serversServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(windowMock.document.createElement).toHaveBeenCalledTimes(1);
expect(windowMock.document.body.appendChild).toHaveBeenCalledTimes(1);
expect(windowMock.document.body.removeChild).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,14 +1,13 @@
import sinon from 'sinon';
import ServersImporter from '../../../src/servers/services/ServersImporter';
describe('ServersImporter', () => {
const servers = [{ name: 'foo' }, { name: 'bar' }];
const csvjsonMock = {
toObject: sinon.fake.returns(servers),
toObject: jest.fn(() => servers),
};
const importer = new ServersImporter(csvjsonMock);
beforeEach(() => csvjsonMock.toObject.resetHistory());
beforeEach(() => csvjsonMock.toObject.mockClear());
describe('importServersFromFile', () => {
it('rejects with error if no file was provided', async () => {
@@ -28,7 +27,7 @@ describe('ServersImporter', () => {
});
it('reads file when a CSV is provided', async () => {
const readAsText = sinon.fake.returns('');
const readAsText = jest.fn(() => '');
global.FileReader = class FileReader {
constructor() {
@@ -40,8 +39,8 @@ describe('ServersImporter', () => {
await importer.importServersFromFile({ type: 'text/csv' });
expect(readAsText.callCount).toEqual(1);
expect(csvjsonMock.toObject.callCount).toEqual(1);
expect(readAsText).toHaveBeenCalledTimes(1);
expect(csvjsonMock.toObject).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,5 +1,3 @@
import sinon from 'sinon';
import { last } from 'ramda';
import ServersService from '../../../src/servers/services/ServersService';
describe('ServersService', () => {
@@ -8,8 +6,8 @@ describe('ServersService', () => {
def456: { id: 'def456' },
};
const createStorageMock = (returnValue) => ({
set: sinon.fake(),
get: sinon.fake.returns(returnValue),
set: jest.fn(),
get: jest.fn(() => returnValue),
});
describe('listServers', () => {
@@ -20,8 +18,8 @@ describe('ServersService', () => {
const result = service.listServers();
expect(result).toEqual({});
expect(storageMock.get.callCount).toEqual(1);
expect(storageMock.set.callCount).toEqual(0);
expect(storageMock.get).toHaveBeenCalledTimes(1);
expect(storageMock.set).not.toHaveBeenCalled();
});
it('returns value from storage when found', () => {
@@ -31,8 +29,8 @@ describe('ServersService', () => {
const result = service.listServers();
expect(result).toEqual(servers);
expect(storageMock.get.callCount).toEqual(1);
expect(storageMock.set.callCount).toEqual(0);
expect(storageMock.get).toHaveBeenCalledTimes(1);
expect(storageMock.set).not.toHaveBeenCalled();
});
});
@@ -44,8 +42,8 @@ describe('ServersService', () => {
const result = service.findServerById('ghi789');
expect(result).toBeUndefined();
expect(storageMock.get.callCount).toEqual(1);
expect(storageMock.set.callCount).toEqual(0);
expect(storageMock.get).toHaveBeenCalledTimes(1);
expect(storageMock.set).not.toHaveBeenCalled();
});
it('returns server from list when found', () => {
@@ -55,8 +53,8 @@ describe('ServersService', () => {
const result = service.findServerById('abc123');
expect(result).toEqual({ id: 'abc123' });
expect(storageMock.get.callCount).toEqual(1);
expect(storageMock.set.callCount).toEqual(0);
expect(storageMock.get).toHaveBeenCalledTimes(1);
expect(storageMock.set).not.toHaveBeenCalled();
});
});
@@ -67,9 +65,9 @@ describe('ServersService', () => {
service.createServer({ id: 'ghi789' });
expect(storageMock.get.callCount).toEqual(1);
expect(storageMock.set.callCount).toEqual(1);
expect(last(storageMock.set.lastCall.args)).toEqual({
expect(storageMock.get).toHaveBeenCalledTimes(1);
expect(storageMock.set).toHaveBeenCalledTimes(1);
expect(storageMock.set).toHaveBeenCalledWith(expect.anything(), {
abc123: { id: 'abc123' },
def456: { id: 'def456' },
ghi789: { id: 'ghi789' },
@@ -84,9 +82,9 @@ describe('ServersService', () => {
service.createServers([{ id: 'ghi789' }, { id: 'jkl123' }]);
expect(storageMock.get.callCount).toEqual(1);
expect(storageMock.set.callCount).toEqual(1);
expect(last(storageMock.set.lastCall.args)).toEqual({
expect(storageMock.get).toHaveBeenCalledTimes(1);
expect(storageMock.set).toHaveBeenCalledTimes(1);
expect(storageMock.set).toHaveBeenCalledWith(expect.anything(), {
abc123: { id: 'abc123' },
def456: { id: 'def456' },
ghi789: { id: 'ghi789' },
@@ -102,9 +100,9 @@ describe('ServersService', () => {
service.deleteServer({ id: 'abc123' });
expect(storageMock.get.callCount).toEqual(1);
expect(storageMock.set.callCount).toEqual(1);
expect(last(storageMock.set.lastCall.args)).toEqual({
expect(storageMock.get).toHaveBeenCalledTimes(1);
expect(storageMock.set).toHaveBeenCalledTimes(1);
expect(storageMock.set).toHaveBeenCalledWith(expect.anything(), {
def456: { id: 'def456' },
});
});

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import moment from 'moment';
import * as sinon from 'sinon';
import { identity } from 'ramda';
import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl';
import DateInput from '../../src/utils/DateInput';
@@ -12,7 +11,7 @@ describe('<CreateShortUrl />', () => {
const shortUrlCreationResult = {
loading: false,
};
const createShortUrl = sinon.spy();
const createShortUrl = jest.fn();
beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '');
@@ -23,7 +22,7 @@ describe('<CreateShortUrl />', () => {
});
afterEach(() => {
wrapper.unmount();
createShortUrl.resetHistory();
createShortUrl.mockReset();
});
it('saves short URL with data set in form controls', (done) => {
@@ -33,6 +32,7 @@ describe('<CreateShortUrl />', () => {
const urlInput = wrapper.find('.form-control-lg');
const tagsInput = wrapper.find(TagsSelector);
const customSlugInput = wrapper.find('#customSlug');
const domain = wrapper.find('#domain');
const maxVisitsInput = wrapper.find('#maxVisits');
const dateInputs = wrapper.find(DateInput);
const validSinceInput = dateInputs.at(0);
@@ -41,6 +41,7 @@ describe('<CreateShortUrl />', () => {
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
domain.simulate('change', { target: { value: 'example.com' } });
maxVisitsInput.simulate('change', { target: { value: '20' } });
validSinceInput.simulate('change', validSince);
validUntilInput.simulate('change', validUntil);
@@ -49,19 +50,17 @@ describe('<CreateShortUrl />', () => {
const form = wrapper.find('form');
form.simulate('submit', { preventDefault: identity });
expect(createShortUrl.callCount).toEqual(1);
expect(createShortUrl.getCall(0).args).toEqual(
[
{
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
},
]
);
expect(createShortUrl).toHaveBeenCalledTimes(1);
expect(createShortUrl).toHaveBeenCalledWith({
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
domain: 'example.com',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
findIfExists: false,
});
done();
});
});

View File

@@ -1,21 +1,17 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import searchBarCreator from '../../src/short-urls/SearchBar';
import SearchField from '../../src/utils/SearchField';
import Tag from '../../src/tags/helpers/Tag';
describe('<SearchBar />', () => {
let wrapper;
const listShortUrlsMock = sinon.spy();
const listShortUrlsMock = jest.fn();
const SearchBar = searchBarCreator({});
afterEach(() => {
listShortUrlsMock.resetHistory();
if (wrapper) {
wrapper.unmount();
}
listShortUrlsMock.mockReset();
wrapper && wrapper.unmount();
});
it('renders a SearchField', () => {
@@ -42,9 +38,9 @@ describe('<SearchBar />', () => {
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
const searchField = wrapper.find(SearchField);
expect(listShortUrlsMock.callCount).toEqual(0);
expect(listShortUrlsMock).not.toHaveBeenCalled();
searchField.simulate('change');
expect(listShortUrlsMock.callCount).toEqual(1);
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
});
it('updates short URLs list when a tag is removed', () => {
@@ -53,8 +49,8 @@ describe('<SearchBar />', () => {
);
const tag = wrapper.find(Tag).first();
expect(listShortUrlsMock.callCount).toEqual(0);
expect(listShortUrlsMock).not.toHaveBeenCalled();
tag.simulate('close');
expect(listShortUrlsMock.callCount).toEqual(1);
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import shortUrlsListCreator, { SORTABLE_FIELDS } from '../../src/short-urls/ShortUrlsList';
describe('<ShortUrlsList />', () => {
let wrapper;
const ShortUrlsRow = () => '';
const listShortUrlsMock = jest.fn();
const resetShortUrlParamsMock = jest.fn();
const ShortUrlsList = shortUrlsListCreator(ShortUrlsRow);
beforeEach(() => {
wrapper = shallow(
<ShortUrlsList
listShortUrls={listShortUrlsMock}
resetShortUrlParams={resetShortUrlParamsMock}
shortUrlsListParams={{
page: '1',
tags: [ 'test tag' ],
searchTerm: 'example.com',
}}
match={{ params: {} }}
location={{}}
loading={false}
error={false}
shortUrlsList={
[
{
shortCode: 'testShortCode',
shortUrl: 'https://www.example.com/testShortUrl',
longUrl: 'https://www.example.com/testLongUrl',
tags: [ 'test tag' ],
},
]
}
/>
);
});
afterEach(() => {
listShortUrlsMock.mockReset();
resetShortUrlParamsMock.mockReset();
wrapper && wrapper.unmount();
});
it('wraps a ShortUrlsList with 1 ShortUrlsRow', () => {
expect(wrapper.find(ShortUrlsRow)).toHaveLength(1);
});
it('should render inner table by default', () => {
expect(wrapper.find('table')).toHaveLength(1);
});
it('should render table header by default', () => {
expect(wrapper.find('table').shallow().find('thead')).toHaveLength(1);
});
it('should render 6 table header cells by default', () => {
expect(wrapper.find('table').shallow()
.find('thead').shallow()
.find('tr').shallow()
.find('th')).toHaveLength(6);
});
it('should render 6 table header cells without order by icon by default', () => {
const thElements = wrapper.find('table').shallow()
.find('thead').shallow()
.find('tr').shallow()
.find('th').map((e) => e.shallow());
for (const thElement of thElements) {
expect(thElement.find(FontAwesomeIcon)).toHaveLength(0);
}
});
it('should render 6 table header cells with conditional order by icon', () => {
const orderDirOptionToIconMap = {
ASC: caretUpIcon,
DESC: caretDownIcon,
};
for (const sortableField of Object.getOwnPropertyNames(SORTABLE_FIELDS)) {
wrapper.setState({ orderField: sortableField, orderDir: undefined });
const [ sortableThElement ] = wrapper.find('table').shallow()
.find('thead').shallow()
.find('tr').shallow()
.find('th')
.filterWhere(
(e) =>
e.text().includes(SORTABLE_FIELDS[sortableField])
);
const sortableThElementWrapper = shallow(sortableThElement);
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0);
for (const orderDir of Object.getOwnPropertyNames(orderDirOptionToIconMap)) {
wrapper.setState({ orderField: sortableField, orderDir });
const [ sortableThElement ] = wrapper.find('table').shallow()
.find('thead').shallow()
.find('tr').shallow()
.find('th')
.filterWhere(
(e) =>
e.text().includes(SORTABLE_FIELDS[sortableField])
);
const sortableThElementWrapper = shallow(sortableThElement);
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(1);
expect(
sortableThElementWrapper.find(FontAwesomeIcon).prop('icon')
).toEqual(orderDirOptionToIconMap[orderDir]);
}
}
});
});

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { mount } from 'enzyme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Modal } from 'reactstrap';
import UseExistingIfFoundInfoIcon from '../../src/short-urls/UseExistingIfFoundInfoIcon';
describe('<UseExistingIfFoundInfoIcon />', () => {
let wrapped;
beforeEach(() => {
wrapped = mount(<UseExistingIfFoundInfoIcon />);
});
afterEach(() => wrapped.unmount());
it('shows modal when icon is clicked', () => {
const icon = wrapped.find(FontAwesomeIcon);
expect(wrapped.find(Modal).prop('isOpen')).toEqual(false);
icon.simulate('click');
expect(wrapped.find(Modal).prop('isOpen')).toEqual(true);
});
});

View File

@@ -3,12 +3,11 @@ import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap';
import * as sinon from 'sinon';
import createCreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
describe('<CreateShortUrlResult />', () => {
let wrapper;
const stateFlagTimeout = sinon.spy();
const stateFlagTimeout = jest.fn();
const createWrapper = (result, error = false) => {
const CreateShortUrlResult = createCreateShortUrlResult(stateFlagTimeout);
@@ -18,7 +17,7 @@ describe('<CreateShortUrlResult />', () => {
};
afterEach(() => {
stateFlagTimeout.resetHistory();
stateFlagTimeout.mockReset();
wrapper && wrapper.unmount();
});
@@ -48,8 +47,8 @@ describe('<CreateShortUrlResult />', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
const copyBtn = wrapper.find(CopyToClipboard);
expect(stateFlagTimeout.callCount).toEqual(0);
expect(stateFlagTimeout).not.toHaveBeenCalled();
copyBtn.simulate('copy');
expect(stateFlagTimeout.callCount).toEqual(1);
expect(stateFlagTimeout).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import * as sinon from 'sinon';
import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal';
describe('<DeleteShortUrlModal />', () => {
@@ -11,7 +10,7 @@ describe('<DeleteShortUrlModal />', () => {
shortCode: 'abc123',
originalUrl: 'https://long-domain.com/foo/bar',
};
const deleteShortUrl = sinon.fake.returns(Promise.resolve());
const deleteShortUrl = jest.fn(() => Promise.resolve());
const createWrapper = (shortUrlDeletion) => {
wrapper = shallow(
<DeleteShortUrlModal
@@ -30,7 +29,7 @@ describe('<DeleteShortUrlModal />', () => {
afterEach(() => {
wrapper && wrapper.unmount();
deleteShortUrl.resetHistory();
deleteShortUrl.mockClear();
});
it('shows threshold error message when threshold error occurs', () => {
@@ -106,9 +105,9 @@ describe('<DeleteShortUrlModal />', () => {
setImmediate(() => {
const form = wrapper.find('form');
expect(deleteShortUrl.callCount).toEqual(0);
expect(deleteShortUrl).not.toHaveBeenCalled();
form.simulate('submit', { preventDefault: identity });
expect(deleteShortUrl.callCount).toEqual(1);
expect(deleteShortUrl).toHaveBeenCalledTimes(1);
done();
});
});

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import { Modal } from 'reactstrap';
import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
@@ -8,10 +7,10 @@ describe('<EditTagsModal />', () => {
let wrapper;
const shortCode = 'abc123';
const TagsSelector = () => '';
const editShortUrlTags = sinon.fake.resolves();
const shortUrlTagsEdited = sinon.fake();
const resetShortUrlsTags = sinon.fake();
const toggle = sinon.fake();
const editShortUrlTags = jest.fn(() => Promise.resolve());
const shortUrlTagsEdited = jest.fn();
const resetShortUrlsTags = jest.fn();
const toggle = jest.fn();
const createWrapper = (shortUrlTags) => {
const EditTagsModal = createEditTagsModal(TagsSelector);
@@ -37,10 +36,10 @@ describe('<EditTagsModal />', () => {
afterEach(() => {
wrapper && wrapper.unmount();
editShortUrlTags.resetHistory();
shortUrlTagsEdited.resetHistory();
resetShortUrlsTags.resetHistory();
toggle.resetHistory();
editShortUrlTags.mockClear();
shortUrlTagsEdited.mockReset();
resetShortUrlsTags.mockReset();
toggle.mockReset();
});
it('resets tags when component is mounted', () => {
@@ -51,7 +50,7 @@ describe('<EditTagsModal />', () => {
error: false,
});
expect(resetShortUrlsTags.callCount).toEqual(1);
expect(resetShortUrlsTags).toHaveBeenCalledTimes(1);
});
it('renders tags selector and save button when loaded', () => {
@@ -92,12 +91,12 @@ describe('<EditTagsModal />', () => {
saveBtn.simulate('click');
expect(editShortUrlTags.callCount).toEqual(1);
expect(editShortUrlTags.getCall(0).args).toEqual([ shortCode, []]);
expect(editShortUrlTags).toHaveBeenCalledTimes(1);
expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, []);
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => {
expect(toggle.callCount).toEqual(1);
expect(toggle).toHaveBeenCalledTimes(1);
done();
});
});
@@ -112,7 +111,7 @@ describe('<EditTagsModal />', () => {
const modal = wrapper.find(Modal);
modal.simulate('closed');
expect(shortUrlTagsEdited.callCount).toEqual(0);
expect(shortUrlTagsEdited).not.toHaveBeenCalled();
});
it('notifies tags have been edited when window is closed after saving', (done) => {
@@ -130,8 +129,8 @@ describe('<EditTagsModal />', () => {
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => {
modal.simulate('closed');
expect(shortUrlTagsEdited.callCount).toEqual(1);
expect(shortUrlTagsEdited.getCall(0).args).toEqual([ shortCode, []]);
expect(shortUrlTagsEdited).toHaveBeenCalledTimes(1);
expect(shortUrlTagsEdited).toHaveBeenCalledWith(shortCode, []);
done();
});
});
@@ -146,6 +145,6 @@ describe('<EditTagsModal />', () => {
const cancelBtn = wrapper.find('.btn-link');
cancelBtn.simulate('click');
expect(toggle.callCount).toEqual(1);
expect(toggle).toHaveBeenCalledTimes(1);
});
});

View File

@@ -3,7 +3,6 @@ import { shallow } from 'enzyme';
import moment from 'moment';
import Moment from 'react-moment';
import { assoc, toString } from 'ramda';
import * as sinon from 'sinon';
import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
import ExternalLink from '../../../src/utils/ExternalLink';
import Tag from '../../../src/tags/helpers/Tag';
@@ -12,7 +11,7 @@ describe('<ShortUrlsRow />', () => {
let wrapper;
const mockFunction = () => '';
const ShortUrlsRowMenu = mockFunction;
const stateFlagTimeout = sinon.spy();
const stateFlagTimeout = jest.fn();
const colorGenerator = {
getColorForKey: mockFunction,
setColorForKey: mockFunction,
@@ -53,7 +52,7 @@ describe('<ShortUrlsRow />', () => {
});
it('renders long URL in third row', () => {
const col = wrapper.find('td').at(2); // eslint-disable-line no-magic-numbers
const col = wrapper.find('td').at(2);
const link = col.find(ExternalLink);
expect(link.prop('href')).toEqual(shortUrl.longUrl);
@@ -61,7 +60,7 @@ describe('<ShortUrlsRow />', () => {
describe('renders list of tags in fourth row', () => {
it('with tags', () => {
const col = wrapper.find('td').at(3); // eslint-disable-line no-magic-numbers
const col = wrapper.find('td').at(3);
const tags = col.find(Tag);
expect(tags).toHaveLength(shortUrl.tags.length);
@@ -75,30 +74,29 @@ describe('<ShortUrlsRow />', () => {
it('without tags', () => {
wrapper.setProps({ shortUrl: assoc('tags', [], shortUrl) });
const col = wrapper.find('td').at(3); // eslint-disable-line no-magic-numbers
const col = wrapper.find('td').at(3);
expect(col.text()).toContain('No tags');
});
});
it('renders visits count in fifth row', () => {
const col = wrapper.find('td').at(4); // eslint-disable-line no-magic-numbers
const col = wrapper.find('td').at(4);
expect(col.text()).toEqual(toString(shortUrl.visitsCount));
});
it('updates state when copied to clipboard', () => {
const col = wrapper.find('td').at(5); // eslint-disable-line no-magic-numbers
const col = wrapper.find('td').at(5);
const menu = col.find(ShortUrlsRowMenu);
expect(menu).toHaveLength(1);
expect(stateFlagTimeout.called).toEqual(false);
expect(stateFlagTimeout).not.toHaveBeenCalled();
menu.simulate('copyToClipboard');
expect(stateFlagTimeout.calledOnce).toEqual(true);
expect(stateFlagTimeout).toHaveBeenCalledTimes(1);
});
it('shows copy hint when state prop is true', () => {
// eslint-disable-next-line no-magic-numbers
const isHidden = () => wrapper.find('td').at(5).find('.short-urls-row__copy-hint').prop('hidden');
expect(isHidden()).toEqual(true);

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import { ButtonDropdown, DropdownItem } from 'reactstrap';
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
@@ -10,7 +9,7 @@ describe('<ShortUrlsRowMenu />', () => {
let wrapper;
const DeleteShortUrlModal = () => '';
const EditTagsModal = () => '';
const onCopyToClipboard = sinon.spy();
const onCopyToClipboard = jest.fn();
const selectedServer = { id: 'abc123' };
const shortUrl = {
shortCode: 'abc123',

View File

@@ -1,4 +1,3 @@
import * as sinon from 'sinon';
import reducer, {
CREATE_SHORT_URL_START,
CREATE_SHORT_URL_ERROR,
@@ -39,9 +38,6 @@ describe('shortUrlCreationReducer', () => {
error: false,
});
});
it('returns provided state on unknown action', () =>
expect(reducer({}, { type: 'unknown' })).toEqual({}));
});
describe('resetCreateShortUrl', () => {
@@ -51,30 +47,27 @@ describe('shortUrlCreationReducer', () => {
describe('createShortUrl', () => {
const createApiClientMock = (result) => ({
createShortUrl: sinon.fake.returns(result),
createShortUrl: jest.fn(() => result),
});
const dispatch = sinon.spy();
const dispatch = jest.fn();
const getState = () => ({});
afterEach(() => dispatch.resetHistory());
afterEach(() => dispatch.mockReset());
it('calls API on success', async () => {
const expectedDispatchCalls = 2;
const result = 'foo';
const apiClientMock = createApiClientMock(Promise.resolve(result));
const dispatchable = createShortUrl(() => apiClientMock)({});
await dispatchable(dispatch, getState);
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL, result }]);
expect(apiClientMock.createShortUrl).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: CREATE_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: CREATE_SHORT_URL, result });
});
it('throws on error', async () => {
const expectedDispatchCalls = 2;
const error = 'Error';
const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = createShortUrl(() => apiClientMock)({});
@@ -85,11 +78,10 @@ describe('shortUrlCreationReducer', () => {
expect(e).toEqual(error);
}
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL_ERROR }]);
expect(apiClientMock.createShortUrl).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: CREATE_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: CREATE_SHORT_URL_ERROR });
});
});
});

View File

@@ -1,4 +1,3 @@
import * as sinon from 'sinon';
import reducer, {
DELETE_SHORT_URL, DELETE_SHORT_URL_ERROR,
DELETE_SHORT_URL_START,
@@ -45,12 +44,6 @@ describe('shortUrlDeletionReducer', () => {
errorData,
});
});
it('returns provided state as is on unknown action', () => {
const state = { foo: 'bar' };
expect(reducer(state, { type: 'unknown' })).toEqual(state);
});
});
describe('resetDeleteShortUrl', () => {
@@ -64,39 +57,37 @@ describe('shortUrlDeletionReducer', () => {
});
describe('deleteShortUrl', () => {
const dispatch = sinon.spy();
const getState = sinon.fake.returns({ selectedServer: {} });
const dispatch = jest.fn();
const getState = jest.fn().mockReturnValue({ selectedServer: {} });
afterEach(() => {
dispatch.resetHistory();
getState.resetHistory();
dispatch.mockReset();
getState.mockClear();
});
it('dispatches proper actions if API client request succeeds', async () => {
const apiClientMock = {
deleteShortUrl: sinon.fake.resolves(''),
deleteShortUrl: jest.fn(() => ''),
};
const shortCode = 'abc123';
const expectedDispatchCalls = 2;
await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_SHORT_URL, shortCode }]);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL, shortCode });
expect(apiClientMock.deleteShortUrl.callCount).toEqual(1);
expect(apiClientMock.deleteShortUrl.getCall(0).args).toEqual([ shortCode ]);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode);
});
it('dispatches proper actions if API client request fails', async () => {
const data = { foo: 'bar' };
const error = { response: { data } };
const apiClientMock = {
deleteShortUrl: sinon.fake.returns(Promise.reject(error)),
deleteShortUrl: jest.fn(() => Promise.reject(error)),
};
const shortCode = 'abc123';
const expectedDispatchCalls = 2;
try {
await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
@@ -104,12 +95,12 @@ describe('shortUrlDeletionReducer', () => {
expect(e).toEqual(error);
}
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_SHORT_URL_ERROR, errorData: data }]);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL_ERROR, errorData: data });
expect(apiClientMock.deleteShortUrl.callCount).toEqual(1);
expect(apiClientMock.deleteShortUrl.getCall(0).args).toEqual([ shortCode ]);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode);
});
});
});

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