Compare commits

...

118 Commits

Author SHA1 Message Date
Alejandro Celaya
d231ed3ede Merge pull request #247 from acelaya-forks/feature/user-agent-improvements
Feature/user agent improvements
2020-04-10 20:06:57 +02:00
Alejandro Celaya
cf6f9028f2 Some more improvements on how chart height is calculated 2020-04-10 19:57:33 +02:00
Alejandro Celaya
7cf49d2c1a Increased minimum charts height 2020-04-10 19:47:42 +02:00
Alejandro Celaya
e37fb1b4bd Updated changelog 2020-04-10 19:29:57 +02:00
Alejandro Celaya
faf5d0bf7b Unified function parsing user agent for browser and os 2020-04-10 19:22:13 +02:00
Alejandro Celaya
6fede88072 Added dependency on bowser to have a more accurate browser and OS detection 2020-04-10 19:16:44 +02:00
Alejandro Celaya
87ffbefa61 Merge pull request #246 from acelaya-forks/feature/create-improvements
Feature/create improvements
2020-04-10 18:50:09 +02:00
Alejandro Celaya
f33ae17781 Updated changelog 2020-04-10 18:43:16 +02:00
Alejandro Celaya
2a2bae6d1a Improved short URL creation 2020-04-10 18:42:08 +02:00
Alejandro Celaya
eb65e99024 Merge pull request #244 from acelaya-forks/feature/chart-visit-highlighting
Feature/chart visit highlighting
2020-04-10 15:21:07 +02:00
Alejandro Celaya
52dbeb6201 Optimized visits parser to act over the normalized list of visits 2020-04-10 14:59:12 +02:00
Alejandro Celaya
fafe920b7b Ensured highlighted stats are properly sorted and paginated on charts that support that 2020-04-10 14:38:31 +02:00
Alejandro Celaya
9d1e48ee90 Updated main list paginator to be sticky 2020-04-10 13:42:21 +02:00
Alejandro Celaya
3851342e1b Added button to reset visits selection 2020-04-10 13:27:01 +02:00
Alejandro Celaya
b863c2e19d Used cursor pointer in bar charts 2020-04-10 13:04:39 +02:00
Alejandro Celaya
ed584d19e5 Ensured charts datasets have a unique label 2020-04-10 12:57:14 +02:00
Alejandro Celaya
73256dcf5b Handled toggling between highlighted chart bars 2020-04-10 12:53:54 +02:00
Alejandro Celaya
c67a23c988 Added support to disable date inputs 2020-04-10 12:25:06 +02:00
Alejandro Celaya
8f42e65ccd Allowed visits to be selected on charts so that they get highlighted on the rest of the charts 2020-04-10 11:59:53 +02:00
Alejandro Celaya
05deb1aff0 Merge pull request #242 from acelaya-forks/feature/visits-table
Feature/visits table
2020-04-09 11:23:51 +02:00
Alejandro Celaya
a74b7cdfad Updated changelog 2020-04-09 11:00:27 +02:00
Alejandro Celaya
1c3119ee76 Allowed multiple selection on visits table 2020-04-09 10:56:54 +02:00
Alejandro Celaya
ca52911e42 Added VisitsTable test 2020-04-09 10:21:38 +02:00
Alejandro Celaya
9177bc7cef Tested how hilghlighted data behaves on GraphCards 2020-04-09 09:44:14 +02:00
Alejandro Celaya
310831a26a Converted ShortUrlVisits in functional component 2020-04-07 22:33:41 +02:00
Alejandro Celaya
8a486d991b Implemented some improvements and fixes on how visits table is split and calculated 2020-04-05 18:04:15 +02:00
Alejandro Celaya
b79333393b Converted SearchField component into funcitonal component 2020-04-05 16:18:08 +02:00
Alejandro Celaya
cb7062bb95 Created fake border with before and after pseudoelements for sticky table cells 2020-04-05 16:02:42 +02:00
Alejandro Celaya
94c5b2c471 Improved useToggle hook so that it also returns enabler and disabler 2020-04-05 12:18:41 +02:00
Alejandro Celaya
66bf26f1dc Improved highlighted data calculation so that it works with values different than 1 2020-04-05 11:57:39 +02:00
Alejandro Celaya
f5cc1abe75 Ensured info for selected visit in visits table gets highlighted in bar charts 2020-04-04 20:16:20 +02:00
Alejandro Celaya
bd4255108d Improved VisitsTable performance by memoizing visits lists 2020-04-04 12:58:04 +02:00
Alejandro Celaya
06b63d1af2 Improved rendering of visits table on mobile devices 2020-04-04 12:09:17 +02:00
Alejandro Celaya
2bd70fb9e6 Fixed unit tests 2020-04-04 10:36:38 +02:00
Alejandro Celaya
e6034dfb14 Created VisitsTable 2020-04-03 23:00:57 +02:00
Alejandro Celaya
c8ba6764c2 Merge pull request #238 from acelaya-forks/feature/edit-long-url
Feature/edit long url
2020-03-30 21:36:20 +02:00
Alejandro Celaya
19337d6c05 Added tests for elements regarding short URL edition 2020-03-30 21:26:30 +02:00
Alejandro Celaya
a6ad3c2d4d Updated changelog 2020-03-30 21:01:54 +02:00
Alejandro Celaya
b0dd885c09 Converted ShortUrlsRowMenu into functional component 2020-03-30 21:01:01 +02:00
Alejandro Celaya
2235592308 Fixed ShortUrlsRowMenu test 2020-03-30 20:50:31 +02:00
Alejandro Celaya
1219a16261 Ensured short URLs list is updated after editing the long URL of a short URL 2020-03-30 20:47:33 +02:00
Alejandro Celaya
7949e224e0 Created modal to edit the loing URL behind a short URL 2020-03-30 20:42:58 +02:00
Alejandro Celaya
ab2f311bb7 Merge pull request #237 from acelaya-forks/feature/short-code-length
Feature/short code length
2020-03-29 19:49:09 +02:00
Alejandro Celaya
a5aab43666 Updated changelog 2020-03-29 19:41:29 +02:00
Alejandro Celaya
74ebd4e572 Converted CreateShortUrl to functional component 2020-03-29 19:36:45 +02:00
Alejandro Celaya
bd29670108 Added short code length field to form to create short URLs 2020-03-29 18:55:41 +02:00
Alejandro Celaya
9a20b4428d Merge pull request #236 from acelaya-forks/feature/progressive-paginator
Feature/progressive paginator
2020-03-28 17:52:35 +01:00
Alejandro Celaya
d7da8521ce Created helper functions to determine the key and if a page is disabled on a progressive paginator 2020-03-28 17:43:09 +01:00
Alejandro Celaya
bab3b252c1 Updated changelog 2020-03-28 17:35:02 +01:00
Alejandro Celaya
7f05c5c2da Split utils module into several helpers modules 2020-03-28 17:33:27 +01:00
Alejandro Celaya
2d5c2779c3 Moved helper functions to render progressive paginators to a common place 2020-03-28 17:25:12 +01:00
Alejandro Celaya
06db4f6556 Used progressive pagination for the short URLs list 2020-03-28 17:19:33 +01:00
Alejandro Celaya
ea5ec63a22 Ensured all branches build the docker image 2020-03-22 09:34:24 +01:00
Alejandro Celaya
f46e737e77 Merge pull request #233 from acelaya-forks/feature/fix-docker-build-condition
Fixed docker build condition so that it's run for any branch or tag a…
2020-03-21 13:39:31 +01:00
Alejandro Celaya
6e63bdaafa Fixed docker build condition so that it's run for any branch or tag as long as it is not a PR 2020-03-21 08:43:35 +01:00
Alejandro Celaya
79ccef9f7e Avoid latest docker to be build when building a tag 2020-03-21 06:59:37 +01:00
Alejandro Celaya
a9653b3674 Merge pull request #232 from acelaya-forks/feature/improve-docker-build
Feature/improve docker build
2020-03-20 09:23:35 +01:00
Alejandro Celaya
b5a188e802 Improved building process so that already generated dist files are reused when building docker image is possible 2020-03-20 09:12:43 +01:00
Alejandro Celaya
38fc402b16 Improved docker build script to avoid duplicating code 2020-03-20 07:12:07 +01:00
Alejandro Celaya
584d1ec1ce Fixed conditional in docker build script 2020-03-19 20:39:34 +01:00
Alejandro Celaya
2ca7faa457 Merge pull request #230 from acelaya-forks/feature/travis-docker-build
Added docker image building as a deployment step for travis
2020-03-19 20:32:00 +01:00
Alejandro Celaya
03806abda0 Changed build steps so that mutation testing a docker build are only run on pull request builds 2020-03-19 20:26:35 +01:00
Alejandro Celaya
18d125430d Added docker image building as a deployment step for travis 2020-03-19 20:04:30 +01:00
Alejandro Celaya
f57f6b7745 Merge pull request #228 from acelaya-forks/feature/memoize-server-version
Feature/memoize server version
2020-03-16 19:01:33 +01:00
Alejandro Celaya
75ff2b8f40 Added app gif to readme 2020-03-16 18:53:06 +01:00
Alejandro Celaya
2ec04c0121 Fixed test by using different serverId every time, preventing memoization 2020-03-16 18:51:04 +01:00
Alejandro Celaya
5145a41dac Memoized the loading of the server version, assuming it will not change at runtime 2020-03-16 13:34:24 +01:00
Alejandro Celaya
25c67f1c3e Merge pull request #227 from acelaya-forks/feature/edit-servers
Feature/edit servers
2020-03-15 14:32:30 +01:00
Alejandro Celaya
77b9181150 Replaced hardcoded color by sass var 2020-03-15 14:23:57 +01:00
Alejandro Celaya
e4f7ded8e2 Updated changelog 2020-03-15 14:04:33 +01:00
Alejandro Celaya
35a62f1fb1 Added link to edit existing servers 2020-03-15 14:03:41 +01:00
Alejandro Celaya
24f2deda46 Moved common code to handle currently selected server to HOC 2020-03-15 13:43:12 +01:00
Alejandro Celaya
5d8af1a0e5 Simplified EditServer component by wrapping ServerForm 2020-03-15 12:02:19 +01:00
Alejandro Celaya
6d44ac1e0c Created common component that can be used both for create and edit servers 2020-03-15 11:59:07 +01:00
Alejandro Celaya
fb0ebddf28 Created component to edit existing servers 2020-03-15 11:29:20 +01:00
Alejandro Celaya
0aebaa4da1 Extracted logic to render horizontal form groups to their own components 2020-03-15 10:50:05 +01:00
Alejandro Celaya
f6baedc655 Converted CreateServer into functional component 2020-03-15 10:33:23 +01:00
Alejandro Celaya
7db222664d Fixed tests 2020-03-15 09:56:16 +01:00
Alejandro Celaya
8223f0fd64 Undone weird changes in package lock file 2020-03-15 09:43:42 +01:00
Alejandro Celaya
f44ec42f51 Added links to delete and edit the server when a server could not be reached 2020-03-15 09:17:33 +01:00
Alejandro Celaya
dab75ab6a9 Updated badges 2020-03-10 21:53:21 +01:00
Alejandro Celaya
01672b88e1 Merge pull request #222 from acelaya-forks/feature/server-not-found
Feature/server not found
2020-03-08 13:17:56 +01:00
Alejandro Celaya
78dc297022 Updated changelog 2020-03-08 13:05:15 +01:00
Alejandro Celaya
c8cf75fa28 Created ServerError test 2020-03-08 13:04:21 +01:00
Alejandro Celaya
b011b4e1d8 Fixed tests 2020-03-08 12:57:01 +01:00
Alejandro Celaya
9804a2d18d Added list of servers connected to store in ServerError component 2020-03-08 12:50:42 +01:00
Alejandro Celaya
d1a5ee43e9 Created components to display errors when loading a server 2020-03-08 12:41:18 +01:00
Alejandro Celaya
febecab33c Migrated Home component to a functional component 2020-03-08 11:35:06 +01:00
Alejandro Celaya
99042c0979 Extracted servers list group from home component to a reusable component 2020-03-08 11:16:57 +01:00
Alejandro Celaya
6395e4e00b Improved NotFount component so that link text is passed as children 2020-03-08 10:28:04 +01:00
Alejandro Celaya
4a69907ca3 Fixed generation of component keys to make them render properly 2020-03-08 10:16:45 +01:00
Alejandro Celaya
c8d682cc98 Handled loading server in just one place, and added error handling for loading servers 2020-03-08 10:00:25 +01:00
Alejandro Celaya
f4cc8d3a0c Fixed default value for vertically aligned items 2020-03-07 12:07:51 +01:00
Alejandro Celaya
6ac89334fd Merge pull request #220 from acelaya-forks/feature/improvements
Feature/improvements
2020-03-06 21:56:20 +01:00
Alejandro Celaya
f55d3a66aa Converted ShortUrlsRow component into a functional component 2020-03-06 21:44:03 +01:00
Alejandro Celaya
972eafab34 Updated changelog 2020-03-06 21:26:19 +01:00
Alejandro Celaya
fba156b271 Moved copy-to-clipboard control next to short URL 2020-03-06 21:25:30 +01:00
Alejandro Celaya
96d538db15 Replaced Unknown by Direct for traffic comming from undetermined referrers 2020-03-06 20:42:22 +01:00
Alejandro Celaya
b89bfa3c1c Merge pull request #215 from acelaya-forks/feature/versions
Feature/versions
2020-03-05 14:20:31 +01:00
Alejandro Celaya
73e3f42614 Added ShlinkVersions test 2020-03-05 13:55:39 +01:00
Alejandro Celaya
e761f5e1bd Updated changelog 2020-03-05 13:45:24 +01:00
Alejandro Celaya
4a6dd66ecd Added scripts to pass version when building docker image 2020-03-05 13:37:07 +01:00
Alejandro Celaya
8e1c6908c6 Updated build script so that it replaces version placeholder when a version is provided 2020-03-05 13:27:57 +01:00
Alejandro Celaya
f59e569e22 Extracted logic to determine app version from function to generate dist file 2020-03-05 13:04:12 +01:00
Alejandro Celaya
be50b24504 Added mechanism to provide a version to shlink-web-client 2020-03-05 12:53:32 +01:00
Alejandro Celaya
c181831a37 Fixed tests 2020-03-05 11:58:35 +01:00
Alejandro Celaya
dbee62ac8c Moved shlink versions component to main container 2020-03-05 11:46:38 +01:00
Alejandro Celaya
1e949b3a22 Added shlink versions to side menu 2020-03-05 11:11:26 +01:00
Alejandro Celaya
b02dcf6c53 Refactored delete server components 2020-03-05 10:18:38 +01:00
Alejandro Celaya
ab7718e335 Removed duplicated code from AsideMenu by creating an AsideMenuItem helper component 2020-03-05 10:03:38 +01:00
Alejandro Celaya
451c77d47f Merge pull request #214 from acelaya-forks/feature/consistent-server-loading
Feature/consistent server loading
2020-03-05 09:32:59 +01:00
Alejandro Celaya
fa0d3d4047 Removed no longer needed async/await when building api client 2020-03-05 09:23:53 +01:00
Alejandro Celaya
397a183f65 Converted MenuLayout into a functional component with hooks 2020-03-05 09:08:50 +01:00
Alejandro Celaya
bc8905ee7f Ensured server is properly loaded before trying to render any children component 2020-03-05 08:59:07 +01:00
Alejandro Celaya
853032ac7f Displayed preloader when a server is being loaded 2020-03-05 08:41:55 +01:00
Alejandro Celaya
3b0e282a52 Merge pull request #211 from acelaya-forks/feature/jest-each
Feature/jest each
2020-02-17 18:32:52 +01:00
Alejandro Celaya
bb28cb3862 Updated changelog 2020-02-17 18:25:21 +01:00
Alejandro Celaya
d0f458bece Uninstalled jest-each and replaced by jest's native each 2020-02-17 18:21:52 +01:00
137 changed files with 4056 additions and 1817 deletions

View File

@@ -1,6 +1,6 @@
./.github ./.github
./build ./build
./coverage ./coverage
./dist
./node_modules ./node_modules
./test ./test
./shlink-web-client.gif

View File

@@ -29,6 +29,7 @@
"no-magic-numbers": "off", "no-magic-numbers": "off",
"no-undefined": "off", "no-undefined": "off",
"no-inline-comments": "off", "no-inline-comments": "off",
"lines-around-comment": "off",
"indent": ["error", 2, { "indent": ["error", 2, {
"SwitchCase": 1 "SwitchCase": 1
} }

View File

@@ -20,21 +20,26 @@ before_script:
script: script:
- npm run lint - npm run lint
- npm run test:ci - npm run test:ci
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi - if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then docker build -t shlink-web-client:test . ; fi
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi - if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then npm run mutate:ci ; fi
after_success: after_success:
- node_modules/.bin/ocular coverage/clover.xml - node_modules/.bin/ocular coverage/clover.xml
# Before deploying, build dist file for current travis tag # Before deploying, build dist file for current travis tag
before_deploy: before_deploy:
- npm run build ${TRAVIS_TAG#?} - if [[ ! -z $TRAVIS_TAG ]]; then npm run build ${TRAVIS_TAG#?} ; fi
deploy: deploy:
provider: releases - provider: script
api_key: script: bash ./scripts/docker/build
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM= on:
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip" all_branches: true
skip_cleanup: true condition: $TRAVIS_PULL_REQUEST == 'false'
on: - provider: releases
tags: true api_key:
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true

View File

@@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.4.0 - 2020-04-10
#### Added
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
* [#241](https://github.com/shlinkio/shlink-web-client/issues/241) Added support to select charts bars in order to highlight related stats in other charts.
It also selects the visits in the new table, and you can even combine a selection in the chart and in the table.
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
#### Changed
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
## 2.3.1 - 2020-02-08 ## 2.3.1 - 2020-02-08
#### Added #### Added

View File

@@ -1,6 +1,14 @@
FROM node:12.14.1-alpine as node FROM node:12.14.1-alpine as node
COPY . /shlink-web-client COPY . /shlink-web-client
RUN cd /shlink-web-client && npm install && npm run build ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \
UNCOMPRESSED="shlink-web-client_${VERSION}_dist" && \
DIST_FILE="./dist/${UNCOMPRESSED}.zip" && \
# If a dist file already exists, just unzip it
if [[ -f ${DIST_FILE} ]]; then unzip ${DIST_FILE} && mv ./${UNCOMPRESSED} ./build ; fi && \
# If no dist file exsts, build from scratch
if [[ ! -f ${DIST_FILE} ]]; then npm install && npm run build -- ${VERSION} --no-dist ; fi
FROM nginx:1.17.7-alpine FROM nginx:1.17.7-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

View File

@@ -1,15 +1,17 @@
# shlink-web-client # 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) [![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/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) [![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) [![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) [![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE) [![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://acel.me/donate) [![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io). A ReactJS-based progressive web application for [Shlink](https://shlink.io).
![shlink-web-client](shlink-web-client.gif)
## Installation ## Installation
There are three ways in which you can use this application. There are three ways in which you can use this application.

1732
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"axios": "^0.19.0", "axios": "^0.19.0",
"bootstrap": "^4.3.1", "bootstrap": "^4.3.1",
"bottlejs": "^1.7.2", "bottlejs": "^1.7.2",
"bowser": "^2.9.0",
"chart.js": "^2.8.0", "chart.js": "^2.8.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"compare-versions": "^3.5.1", "compare-versions": "^3.5.1",
@@ -43,13 +44,13 @@
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qs": "^6.9.0", "qs": "^6.9.0",
"ramda": "^0.26.1", "ramda": "^0.26.1",
"react": "^16.10.2", "react": "^16.13.1",
"react-autosuggest": "^9.4.3", "react-autosuggest": "^9.4.3",
"react-chartjs-2": "^2.8.0", "react-chartjs-2": "^2.8.0",
"react-color": "^2.17.3", "react-color": "^2.17.3",
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "~1.5.0", "react-datepicker": "~1.5.0",
"react-dom": "^16.10.2", "react-dom": "^16.13.1",
"react-external-link": "^1.0.0", "react-external-link": "^1.0.0",
"react-leaflet": "^2.4.0", "react-leaflet": "^2.4.0",
"react-moment": "^0.9.5", "react-moment": "^0.9.5",
@@ -85,8 +86,8 @@
"css-loader": "^3.2.0", "css-loader": "^3.2.0",
"dotenv": "^8.1.0", "dotenv": "^8.1.0",
"dotenv-expand": "^5.1.0", "dotenv-expand": "^5.1.0",
"enzyme": "^3.10.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.14.0", "enzyme-adapter-react-16": "^1.15.2",
"eslint": "^5.11.1", "eslint": "^5.11.1",
"eslint-config-adidas-babel": "^1.1.0", "eslint-config-adidas-babel": "^1.1.0",
"eslint-config-adidas-env": "^1.1.0", "eslint-config-adidas-env": "^1.1.0",
@@ -104,7 +105,6 @@
"html-webpack-plugin": "^4.0.0-beta.8", "html-webpack-plugin": "^4.0.0-beta.8",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^24.9.0", "jest": "^24.9.0",
"jest-each": "^24.9.0",
"jest-pnp-resolver": "^1.2.1", "jest-pnp-resolver": "^1.2.1",
"jest-resolve": "^24.9.0", "jest-resolve": "^24.9.0",
"mini-css-extract-plugin": "^0.8.0", "mini-css-extract-plugin": "^0.8.0",

View File

@@ -14,7 +14,6 @@ process.on('unhandledRejection', (err) => {
// Ensure environment variables are read. // Ensure environment variables are read.
require('../config/env'); require('../config/env');
const path = require('path');
const chalk = require('chalk'); const chalk = require('chalk');
const fs = require('fs-extra'); const fs = require('fs-extra');
const webpack = require('webpack'); const webpack = require('webpack');
@@ -22,7 +21,6 @@ const bfj = require('bfj');
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError'); const printBuildError = require('react-dev-utils/printBuildError');
const { checkBrowsers } = require('react-dev-utils/browsersHelper'); const { checkBrowsers } = require('react-dev-utils/browsersHelper');
@@ -30,7 +28,6 @@ const paths = require('../config/paths');
const configFactory = require('../config/webpack.config'); const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter; const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them. // These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
@@ -47,6 +44,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
const argvSliceStart = 2; const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart); const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.indexOf('--stats') !== -1; const writeStatsJson = argv.indexOf('--stats') !== -1;
const withoutDist = argv.indexOf('--no-dist') !== -1;
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration // Generate configuration
const config = configFactory('production'); const config = configFactory('production');
@@ -85,6 +84,7 @@ checkBrowsers(paths.appPath, isInteractive)
); );
} else { } else {
console.log(chalk.green('Compiled successfully.\n')); console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
} }
console.log('File sizes after gzip:\n'); console.log('File sizes after gzip:\n');
@@ -96,20 +96,6 @@ checkBrowsers(paths.appPath, isInteractive)
WARN_AFTER_CHUNK_GZIP_SIZE WARN_AFTER_CHUNK_GZIP_SIZE
); );
console.log(); console.log();
const appPackage = require(paths.appPackageJson);
const { publicUrl } = paths;
const { output: { publicPath } } = config;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
publicUrl,
publicPath,
buildFolder,
useYarn
);
}, },
(err) => { (err) => {
console.log(chalk.red('Failed to compile.\n')); console.log(chalk.red('Failed to compile.\n'));
@@ -117,7 +103,7 @@ checkBrowsers(paths.appPath, isInteractive)
process.exit(1); process.exit(1);
} }
) )
.then(zipDist) .then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => { .catch((err) => {
if (err && err.message) { if (err && err.message) {
console.log(err.message); console.log(err.message);
@@ -200,15 +186,7 @@ function copyPublicFolder() {
}); });
} }
function zipDist() { function zipDist(version) {
const minArgsToContainVersion = 3;
// If no version was provided, do nothing
if (process.argv.length < minArgsToContainVersion) {
return;
}
const [ , , version ] = process.argv;
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`; const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`)); console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
@@ -226,4 +204,24 @@ function zipDist() {
console.log(chalk.red('An error occurred while generating dist file')); console.log(chalk.red('An error occurred while generating dist file'));
console.log(e); console.log(e);
} }
console.log();
}
function getVersionFromArgs(argv) {
const [ version ] = argv;
return { version, hasVersion: !!version };
}
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
} }

13
scripts/docker/build Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
if [[ -z $TRAVIS_TAG ]]; then
docker build -t shlinkio/shlink-web-client:latest .
docker push shlinkio/shlink-web-client:latest
else
docker build --build-arg VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:stable .
docker push shlinkio/shlink-web-client:${TRAVIS_TAG#?}
docker push shlinkio/shlink-web-client:stable
fi

BIN
shlink-web-client.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -3,14 +3,15 @@ import { Route, Switch } from 'react-router-dom';
import './App.scss'; import './App.scss';
import NotFound from './common/NotFound'; import NotFound from './common/NotFound';
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => ( const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer) => () => (
<div className="container-fluid app-container"> <div className="container-fluid app-container">
<MainHeader /> <MainHeader />
<div className="app"> <div className="app">
<Switch> <Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} /> <Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>

View File

@@ -1,16 +1,34 @@
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; import {
faList as listIcon,
faLink as createIcon,
faTags as tagsIcon,
faPen as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classNames from 'classnames';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import './AsideMenu.scss'; import './AsideMenu.scss';
const defaultProps = { const AsideMenuItem = ({ children, to, className, ...rest }) => (
className: '', <NavLink
showOnMobile: false, className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected"
to={to}
{...rest}
>
{children}
</NavLink>
);
AsideMenuItem.propTypes = {
children: PropTypes.node.isRequired,
to: PropTypes.string.isRequired,
className: PropTypes.string,
}; };
const propTypes = { const propTypes = {
selectedServer: serverType, selectedServer: serverType,
className: PropTypes.string, className: PropTypes.string,
@@ -20,43 +38,34 @@ const propTypes = {
const AsideMenu = (DeleteServerButton) => { const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => { const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : ''; const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, { const asideClass = classNames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
}); });
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls'); const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
return ( return (
<aside className={asideClass}> <aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav"> <nav className="nav flex-column aside-menu__nav">
<NavLink <AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<FontAwesomeIcon icon={listIcon} /> <FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span> <span className="aside-menu__item-text">List short URLs</span>
</NavLink> </AsideMenuItem>
<NavLink <AsideMenuItem to={buildPath('/create-short-url')}>
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} flip="horizontal" /> <FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span> <span className="aside-menu__item-text">Create short URL</span>
</NavLink> </AsideMenuItem>
<AsideMenuItem to={buildPath('/manage-tags')}>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} /> <FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span> <span className="aside-menu__item-text">Manage tags</span>
</NavLink> </AsideMenuItem>
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem>
<DeleteServerButton <DeleteServerButton
className="aside-menu__item aside-menu__item--danger" className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer} server={selectedServer}
/> />
</nav> </nav>
@@ -64,7 +73,6 @@ const AsideMenu = (DeleteServerButton) => {
); );
}; };
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes; AsideMenu.propTypes = propTypes;
return AsideMenu; return AsideMenu;

View File

@@ -67,6 +67,9 @@ $asideMenuMobileWidth: 280px;
.aside-menu__item--danger { .aside-menu__item--danger {
color: $dangerColor; color: $dangerColor;
}
.aside-menu__item--push {
margin-top: auto; margin-top: auto;
} }

View File

@@ -1,52 +1,35 @@
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons'; import React, { useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Home.scss'; import './Home.scss';
import ServersListGroup from '../servers/ServersListGroup';
export default class Home extends React.Component { const propTypes = {
static propTypes = { resetSelectedServer: PropTypes.func,
resetSelectedServer: PropTypes.func, servers: PropTypes.object,
servers: PropTypes.object, };
};
componentDidMount() { const Home = ({ resetSelectedServer, servers: { list, loading } }) => {
this.props.resetSelectedServer(); const servers = values(list);
} const hasServers = !isEmpty(servers);
render() { useEffect(() => {
const { servers: { list, loading } } = this.props; resetSelectedServer();
const servers = values(list); }, []);
const hasServers = !isEmpty(servers);
return ( return (
<div className="home"> <div className="home">
<h1 className="home__title">Welcome to Shlink</h1> <h1 className="home__title">Welcome to Shlink</h1>
<h5 className="home__intro"> <ServersListGroup servers={servers}>
{!loading && hasServers && <span>Please, select a server.</span>} {!loading && hasServers && <span>Please, select a server.</span>}
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>} {!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{loading && <span>Trying to load servers...</span>} {loading && <span>Trying to load servers...</span>}
</h5> </ServersListGroup>
</div>
);
};
{!loading && hasServers && ( Home.propTypes = propTypes;
<ListGroup className="home__servers-list">
{servers.map(({ name, id }) => ( export default Home;
<ListGroupItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
className="home__servers-item"
>
{name}
<FontAwesomeIcon icon={chevronIcon} className="home__servers-item-icon" />
</ListGroupItem>
))}
</ListGroup>
)}
</div>
);
}
}

View File

@@ -1,5 +1,4 @@
@import '../utils/base'; @import '../utils/base';
@import '../utils/mixins/vertical-align';
.home { .home {
text-align: center; text-align: center;
@@ -17,21 +16,3 @@
font-size: 2.2rem; font-size: 2.2rem;
} }
} }
.home__servers-list {
margin-top: 1rem;
width: 100%;
max-width: 400px;
}
.home__servers-item.home__servers-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.home__servers-item-icon {
@include vertical-align();
right: 1rem;
}

View File

@@ -1,110 +1,82 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom'; 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 { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames'; import classNames from 'classnames';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useToggle } from '../utils/helpers/hooks';
import NotFound from './NotFound'; import NotFound from './NotFound';
import './MenuLayout.scss'; import './MenuLayout.scss';
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) => const propTypes = {
class MenuLayout extends React.Component { match: PropTypes.object,
static propTypes = { location: PropTypes.object,
match: PropTypes.object, selectedServer: serverType,
selectServer: PropTypes.func, };
location: PropTypes.object,
selectedServer: serverType, const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
const MenuLayoutComp = ({ match, location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
const { params: { serverId } } = match;
useEffect(() => hideSidebar(), [ location ]);
if (selectedServer.serverNotReachable) {
return <ServerError type="not-reachable" />;
}
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible,
});
const swipeMenuIfNoModalExists = (callback) => () => {
if (document.querySelector('.modal')) {
return;
}
callback();
}; };
state = { showSideBar: false }; return (
<React.Fragment>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
componentDidMount() { <Swipeable
const { match, selectServer } = this.props; delta={40}
const { params: { serverId } } = match; className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
selectServer(serverId); onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
} >
<div className="row menu-layout__swipeable-inner">
componentDidUpdate(prevProps) { <AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
const { location } = this.props; <div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer, match } = this.props;
const { params: { serverId } } = match;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
if (document.querySelector('.modal')) {
return;
}
this.setState({ showSideBar });
};
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Switch> <Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route <Route
exact render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/> />
</Switch> </Switch>
</div> </div>
<div className="menu-layout__footer text-center text-md-right">
<ShlinkVersions />
</div>
</div> </div>
</Swipeable> </div>
</React.Fragment> </Swipeable>
); </React.Fragment>
} );
}; };
MenuLayoutComp.propTypes = propTypes;
return withSelectedServer(MenuLayoutComp, ServerError);
};
export default MenuLayout; export default MenuLayout;

View File

@@ -32,3 +32,26 @@
.menu-layout__burger-icon--active { .menu-layout__burger-icon--active {
color: white; color: white;
} }
$footer-height: 2.3rem;
$footer-margin: .8rem;
.menu-layout__container {
padding: 20px 0 ($footer-height + $footer-margin);
min-height: 100%;
margin-bottom: -($footer-height + $footer-margin);
@media (min-width: $mdMin) {
padding: 30px 15px ($footer-height + $footer-margin);
}
}
.menu-layout__footer {
height: $footer-height;
margin-top: $footer-margin;
padding: 0;
@media (min-width: $mdMin) {
padding: 0 15px;
}
}

View File

@@ -4,17 +4,18 @@ import * as PropTypes from 'prop-types';
const propTypes = { const propTypes = {
to: PropTypes.string, to: PropTypes.string,
btnText: PropTypes.string, children: PropTypes.node,
}; };
const NotFound = ({ to = '/', btnText = 'Home' }) => ( const NotFound = ({ to = '/', children = 'Home' }) => (
<div className="home"> <div className="home">
<h2>Oops! We could not find requested route.</h2> <h2>Oops! We could not find requested route.</h2>
<p> <p>
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button. Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
button.
</p> </p>
<br /> <br />
<Link to={to} className="btn btn-outline-primary btn-lg">{btnText}</Link> <Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
</div> </div>
); );

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { pipe } from 'ramda';
import { serverType } from '../servers/prop-types';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const propTypes = {
selectedServer: serverType,
className: PropTypes.string,
clientVersion: PropTypes.string,
};
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
const { printableVersion: serverVersion } = selectedServer;
const normalizedClientVersion = pipe(versionToSemVer(), versionToPrintable)(clientVersion);
return (
<small className={classNames('text-muted', className)}>
Client: <b>{normalizedClientVersion}</b> - Server: <b>{serverVersion}</b>
</small>
);
};
ShlinkVersions.propTypes = propTypes;
export default ShlinkVersions;

View File

@@ -1,38 +1,18 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { range, max, min } from 'ramda'; import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
import './SimplePaginator.scss'; import './SimplePaginator.scss';
const propTypes = { const propTypes = {
pagesCount: PropTypes.number.isRequired, pagesCount: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired, currentPage: PropTypes.number.isRequired,
setCurrentPage: PropTypes.func.isRequired, setCurrentPage: PropTypes.func.isRequired,
centered: PropTypes.bool,
}; };
export const ellipsis = '...'; const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
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) { if (pagesCount < 2) {
return null; return null;
} }
@@ -40,17 +20,17 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
const onClick = (page) => () => setCurrentPage(page); const onClick = (page) => () => setCurrentPage(page);
return ( return (
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator"> <Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
<PaginationItem disabled={currentPage <= 1}> <PaginationItem disabled={currentPage <= 1}>
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} /> <PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
</PaginationItem> </PaginationItem>
{pagination(currentPage, pagesCount).map((page, index) => ( {progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
<PaginationItem <PaginationItem
key={page !== ellipsis ? page : `${page}_${index}`} key={keyForPage(pageNumber, index)}
active={page === currentPage} disabled={isPageDisabled(pageNumber)}
disabled={page === ellipsis} active={currentPage === pageNumber}
> >
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink> <PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
</PaginationItem> </PaginationItem>
))} ))}
<PaginationItem disabled={currentPage >= pagesCount}> <PaginationItem disabled={currentPage >= pagesCount}>

View File

@@ -4,6 +4,7 @@ import Home from '../Home';
import MenuLayout from '../MenuLayout'; import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu'; import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler'; import ErrorHandler from '../ErrorHandler';
import ShlinkVersions from '../ShlinkVersions';
const provideServices = (bottle, connect, withRouter) => { const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window); bottle.constant('window', global.window);
@@ -25,13 +26,18 @@ const provideServices = (bottle, connect, withRouter) => {
'ShortUrls', 'ShortUrls',
'AsideMenu', 'AsideMenu',
'CreateShortUrl', 'CreateShortUrl',
'ShortUrlVisits' 'ShortUrlVisits',
'ShlinkVersions',
'ServerError'
); );
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter); bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton'); bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console'); bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
}; };

View File

@@ -26,7 +26,7 @@ const connect = (propsFromState, actionServiceNames = []) =>
actionServiceNames.reduce(mapActionService, {}) actionServiceNames.reduce(mapActionService, {})
); );
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer'); bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer');
provideCommonServices(bottle, connect, withRouter); provideCommonServices(bottle, connect, withRouter);
provideShortUrlsServices(bottle, connect); provideShortUrlsServices(bottle, connect);

View File

@@ -10,10 +10,6 @@ body,
outline: none !important; outline: none !important;
} }
.nowrap {
white-space: nowrap;
}
.bg-main { .bg-main {
background-color: $mainColor !important; background-color: $mainColor !important;
} }
@@ -28,14 +24,6 @@ body,
color: inherit !important; color: inherit !important;
} }
.shlink-container {
padding: 20px 0;
@media (min-width: $mdMin) {
padding: 30px 15px;
}
}
.badge-main { .badge-main {
color: #fff; color: #fff;
background-color: $mainColor; background-color: $mainColor;

View File

@@ -7,6 +7,7 @@ import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta'; import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList'; import tagsListReducer from '../tags/reducers/tagsList';
@@ -22,6 +23,7 @@ export default combineReducers({
shortUrlDeletion: shortUrlDeletionReducer, shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer, shortUrlTags: shortUrlTagsReducer,
shortUrlMeta: shortUrlMetaReducer, shortUrlMeta: shortUrlMetaReducer,
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer, shortUrlVisits: shortUrlVisitsReducer,
shortUrlDetail: shortUrlDetailReducer, shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer, tagsList: tagsListReducer,

View File

@@ -1,91 +1,56 @@
import { assoc, dissoc, pipe } from 'ramda'; import React, { useEffect } from 'react';
import React from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './CreateServer.scss'; import './CreateServer.scss';
import { ServerForm } from './helpers/ServerForm';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
const propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
resetSelectedServer: PropTypes.func,
};
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component { const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
static propTypes = { const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
createServer: PropTypes.func, const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
history: PropTypes.shape({ const handleSubmit = (serverData) => {
push: PropTypes.func, const id = uuid();
}), const server = { id, ...serverData };
resetSelectedServer: PropTypes.func,
};
state = { createServer(server);
name: '', push(`/server/${id}/list-short-urls/1`);
url: '', };
apiKey: '',
serversImported: false,
};
handleSubmit = (e) => { useEffect(() => {
e.preventDefault(); resetSelectedServer();
}, []);
const { createServer, history: { push } } = this.props;
const server = pipe(
assoc('id', uuid()),
dissoc('serversImported')
)(this.state);
createServer(server);
push(`/server/${server.id}/list-short-urls/1`);
};
componentDidMount() {
this.props.resetSelectedServer();
}
render() {
const renderInputGroup = (id, placeholder, type = 'text') => (
<div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
{placeholder}:
</label>
<div className="col-lg-11 col-md-10">
<input
type={type}
className="form-control"
id={id}
placeholder={placeholder}
value={this.state[id]}
required
onChange={(e) => this.setState({ [id]: e.target.value })}
/>
</div>
</div>
);
return ( return (
<div className="create-server"> <div className="create-server">
<form onSubmit={this.handleSubmit}> <ServerForm onSubmit={handleSubmit}>
{renderInputGroup('name', 'Name')} <ImportServersBtn onImport={setServersImported} />
{renderInputGroup('url', 'URL', 'url')} <button className="btn btn-outline-primary">Create server</button>
{renderInputGroup('apiKey', 'API key')} </ServerForm>
<div className="text-right"> {serversImported && (
<ImportServersBtn <div className="row create-server__import-success-msg">
onImport={() => stateFlagTimeout(this.setState.bind(this), 'serversImported', true, SHOW_IMPORT_MSG_TIME)} <div className="col-md-10 offset-md-1">
/> <div className="p-2 mt-3 bg-main text-white text-center">
<button className="btn btn-outline-primary">Create server</button> Servers properly imported. You can now select one from the list :)
</div>
{this.state.serversImported && (
<div className="row create-server__import-success-msg">
<div className="col-md-10 offset-md-1">
<div className="p-2 mt-3 bg-main text-white text-center">
Servers properly imported. You can now select one from the list :)
</div>
</div> </div>
</div> </div>
)} </div>
</form> )}
</div> </div>
); );
} };
CreateServerComp.propTypes = propTypes;
return CreateServerComp;
}; };
export default CreateServer; export default CreateServer;

View File

@@ -1,40 +1,36 @@
import React from 'react';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons'; import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useToggle } from '../utils/helpers/hooks';
import { serverType } from './prop-types'; import { serverType } from './prop-types';
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component { const propTypes = {
static propTypes = { server: serverType,
server: serverType, className: PropTypes.string,
className: PropTypes.string, textClassName: PropTypes.string,
}; children: PropTypes.node,
};
state = { isModalOpen: false }; const DeleteServerButton = (DeleteServerModal) => {
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
render() { const [ isModalOpen, , showModal, hideModal ] = useToggle();
const { server, className } = this.props;
return ( return (
<React.Fragment> <React.Fragment>
<span <span className={className} onClick={showModal}>
className={className} {!children && <FontAwesomeIcon icon={deleteIcon} />}
key="deleteServerBtn" <span className={textClassName}>{children || 'Remove this server'}</span>
onClick={() => this.setState({ isModalOpen: true })}
>
<FontAwesomeIcon icon={deleteIcon} />
<span className="aside-menu__item-text">Delete this server</span>
</span> </span>
<DeleteServerModal <DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
isOpen={this.state.isModalOpen}
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
server={server}
key="deleteServerModal"
/>
</React.Fragment> </React.Fragment>
); );
} };
DeleteServerButtonComp.propTypes = propTypes;
return DeleteServerButtonComp;
}; };
export default DeleteServerButton; export default DeleteServerButton;

View File

@@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered> <Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader> <ModalHeader toggle={toggle}><span className="text-danger">Remove server</span></ModalHeader>
<ModalBody> <ModalBody>
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p> <p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p> <p>
No data will be deleted, only the access to that server will be removed from this host. <i>
You can create it again at any moment. No data will be deleted, only the access to this server will be removed from this host.
You can create it again at any moment.
</i>
</p> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>

34
src/servers/EditServer.js Normal file
View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { serverType } from './prop-types';
const propTypes = {
editServer: PropTypes.func,
selectedServer: serverType,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export const EditServer = (ServerError) => {
const EditServerComp = ({ editServer, selectedServer, history: { push } }) => {
const handleSubmit = (serverData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}/list-short-urls/1`);
};
return (
<div className="create-server">
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
<button className="btn btn-outline-primary">Save</button>
</ServerForm>
</div>
);
};
EditServerComp.propTypes = propTypes;
return withSelectedServer(EditServerComp, ServerError);
};

View File

@@ -8,7 +8,6 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
static propTypes = { static propTypes = {
servers: PropTypes.object, servers: PropTypes.object,
selectedServer: serverType, selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func, listServers: PropTypes.func,
history: PropTypes.shape({ history: PropTypes.shape({
push: PropTypes.func, push: PropTypes.func,
@@ -16,14 +15,10 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
}; };
renderServers = () => { renderServers = () => {
const { servers: { list, loading }, selectedServer, selectServer } = this.props; const { servers: { list, loading }, selectedServer } = this.props;
const servers = values(list); const servers = values(list);
const { push } = this.props.history; const { push } = this.props.history;
const loadServer = (id) => { const loadServer = (id) => push(`/server/${id}/list-short-urls/1`);
selectServer(id)
.then(() => push(`/server/${id}/list-short-urls/1`))
.catch(() => {});
};
if (loading) { if (loading) {
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>; return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
@@ -41,10 +36,7 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
</DropdownItem> </DropdownItem>
))} ))}
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem <DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
className="servers-dropdown__export-item"
onClick={() => serversExporter.exportServers()}
>
Export servers Export servers
</DropdownItem> </DropdownItem>
</React.Fragment> </React.Fragment>

View File

@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ListGroup, ListGroupItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { serverType } from './prop-types';
import './ServersListGroup.scss';
const propTypes = {
servers: PropTypes.arrayOf(serverType).isRequired,
children: PropTypes.node.isRequired,
};
const ServerListItem = ({ id, name }) => (
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
{name}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
</ListGroupItem>
);
ServerListItem.propTypes = {
id: PropTypes.string,
name: PropTypes.string,
};
const ServersListGroup = ({ servers, children }) => (
<React.Fragment>
<div className="container">
<h5>{children}</h5>
</div>
{servers.length > 0 && (
<ListGroup className="servers-list__list-group mt-md-3">
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</ListGroup>
)}
</React.Fragment>
);
ServersListGroup.propTypes = propTypes;
export default ServersListGroup;

View File

@@ -0,0 +1,18 @@
@import '../utils/mixins/vertical-align';
.servers-list__list-group {
width: 100%;
max-width: 400px;
}
.servers-list__server-item.servers-list__server-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item-icon {
@include vertical-align();
right: 1rem;
}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { compareVersions } from '../../utils/utils';
import { serverType } from '../prop-types'; import { serverType } from '../prop-types';
import { versionMatch } from '../../utils/helpers/version';
const propTypes = { const propTypes = {
minVersion: PropTypes.string, minVersion: PropTypes.string,
@@ -16,10 +16,9 @@ const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children })
} }
const { version } = selectedServer; const { version } = selectedServer;
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion); const matchesVersion = versionMatch(version, { maxVersion, minVersion });
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
if (!matchesMinVersion || !matchesMaxVersion) { if (!matchesVersion) {
return null; return null;
} }

View File

@@ -0,0 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { serverType } from '../prop-types';
import './ServerError.scss';
const propTypes = {
servers: PropTypes.object,
selectedServer: serverType,
type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired,
};
export const ServerError = (DeleteServerButton) => {
const ServerErrorComp = ({ type, servers: { list }, selectedServer }) => (
<div className="server-error__container flex-column">
<div className="row w-100 mb-3 mb-md-5">
<Message type="error">
{type === 'not-found' && 'Could not find this Shlink server.'}
{type === 'not-reachable' && (
<React.Fragment>
<p>Oops! Could not connect to this Shlink server.</p>
Make sure you have internet connection, and the server is properly configured and on-line.
</React.Fragment>
)}
</Message>
</div>
<ServersListGroup servers={Object.values(list)}>
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup>
{type === 'not-reachable' && (
<div className="container mt-3 mt-md-5">
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
</h5>
</div>
)}
</div>
);
ServerErrorComp.propTypes = propTypes;
return ServerErrorComp;
};

View File

@@ -0,0 +1,17 @@
@import '../../utils/base';
.server-error__container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.server-error__delete-btn {
color: $dangerColor;
cursor: pointer;
}
.server-error__delete-btn:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
const propTypes = {
onSubmit: PropTypes.func.isRequired,
initialValues: PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
apiKey: PropTypes.string.isRequired,
}),
children: PropTypes.node.isRequired,
};
export const ServerForm = ({ onSubmit, initialValues, children }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
const [ apiKey, setApiKey ] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ name, url, apiKey });
};
useEffect(() => {
initialValues && setName(initialValues.name);
initialValues && setUrl(initialValues.url);
initialValues && setApiKey(initialValues.apiKey);
}, [ initialValues ]);
return (
<form onSubmit={handleSubmit}>
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
<div className="text-right">{children}</div>
</form>
);
};
ServerForm.propTypes = propTypes;

View File

@@ -0,0 +1,35 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import Message from '../../utils/Message';
import { serverType } from '../prop-types';
const propTypes = {
selectServer: PropTypes.func,
selectedServer: serverType,
match: PropTypes.object,
};
export const withSelectedServer = (WrappedComponent, ServerError) => {
const Component = (props) => {
const { selectServer, selectedServer, match } = props;
const { params: { serverId } } = match;
useEffect(() => {
selectServer(serverId);
}, [ serverId ]);
if (!selectedServer) {
return <Message loading />;
}
if (selectedServer.serverNotFound) {
return <ServerError type="not-found" />;
}
return <WrappedComponent {...props} />;
};
Component.propTypes = propTypes;
return Component;
};

View File

@@ -1,9 +1,20 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
export const serverType = PropTypes.shape({ const regularServerType = PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
name: PropTypes.string, name: PropTypes.string,
url: PropTypes.string, url: PropTypes.string,
apiKey: PropTypes.string, apiKey: PropTypes.string,
version: PropTypes.string, version: PropTypes.string,
printableVersion: PropTypes.string,
serverNotReachable: PropTypes.bool,
}); });
const notFoundServerType = PropTypes.shape({
serverNotFound: PropTypes.bool.isRequired,
});
export const serverType = PropTypes.oneOfType([
regularServerType,
notFoundServerType,
]);

View File

@@ -1,6 +1,7 @@
import { createAction, handleActions } from 'redux-actions'; import { createAction, handleActions } from 'redux-actions';
import { identity, memoizeWith, pipe } from 'ramda';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams'; import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionIsValidSemVer } from '../../utils/utils'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
@@ -12,26 +13,50 @@ export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
const initialState = null; const initialState = null;
const versionToSemVer = pipe(
(version) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
toSemVer(MIN_FALLBACK_VERSION)
);
const getServerVersion = memoizeWith(identity, (serverId, health) => health().then(({ version }) => ({
version: versionToSemVer(version),
printableVersion: versionToPrintable(version),
})));
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER); export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => { export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
dispatch(resetSelectedServer());
dispatch(resetShortUrlParams()); dispatch(resetShortUrlParams());
const selectedServer = 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({ if (!selectedServer) {
type: SELECT_SERVER, dispatch({
selectedServer: { type: SELECT_SERVER,
...selectedServer, selectedServer: { serverNotFound: true },
version, });
},
}); return;
}
try {
const { health } = buildShlinkApiClient(selectedServer);
const { version, printableVersion } = await getServerVersion(serverId, health);
dispatch({
type: SELECT_SERVER,
selectedServer: {
...selectedServer,
version,
printableVersion,
},
});
} catch (e) {
dispatch({
type: SELECT_SERVER,
selectedServer: { ...selectedServer, serverNotReachable: true },
});
}
}; };
export default handleActions({ export default handleActions({

View File

@@ -52,6 +52,8 @@ export const listServers = ({ listServers, createServers }, { get }) => () => as
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction); export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
export const editServer = ({ editServer }, listServersAction) => pipe(editServer, listServersAction);
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction); export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
export const createServers = ({ createServers }, listServersAction) => pipe( export const createServers = ({ createServers }, listServersAction) => pipe(

View File

@@ -25,4 +25,14 @@ export default class ServersService {
deleteServer = ({ id }) => deleteServer = ({ id }) =>
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers())); this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
editServer = (id, serverData) => {
const allServers = this.listServers();
if (!allServers[id]) {
return;
}
this.storage.set(SERVERS_STORAGE_KEY, assoc(id, { ...allServers[id], ...serverData }, allServers));
}
} }

View File

@@ -3,22 +3,27 @@ import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown'; import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal'; import DeleteServerModal from '../DeleteServerModal';
import DeleteServerButton from '../DeleteServerButton'; import DeleteServerButton from '../DeleteServerButton';
import { EditServer } from '../EditServer';
import ImportServersBtn from '../helpers/ImportServersBtn'; import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer'; import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server'; import { createServer, createServers, deleteServer, editServer, listServers } from '../reducers/server';
import ForServerVersion from '../helpers/ForServerVersion'; import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError';
import ServersImporter from './ServersImporter'; import ServersImporter from './ServersImporter';
import ServersService from './ServersService'; import ServersService from './ServersService';
import ServersExporter from './ServersExporter'; import ServersExporter from './ServersExporter';
const provideServices = (bottle, connect, withRouter) => { const provideServices = (bottle, connect, withRouter) => {
// Components // Components
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout'); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ])); bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter'); bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', withRouter); bottle.decorator('ServersDropdown', withRouter);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ])); bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter); bottle.decorator('DeleteServerModal', withRouter);
@@ -32,6 +37,9 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('ForServerVersion', () => ForServerVersion); bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ])); bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
// Services // Services
bottle.constant('csvjson', csvjson); bottle.constant('csvjson', csvjson);
bottle.service('ServersImporter', ServersImporter, 'csvjson'); bottle.service('ServersImporter', ServersImporter, 'csvjson');
@@ -43,6 +51,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers'); bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers'); bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers'); bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('editServer', editServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios'); bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);

View File

@@ -1,55 +1,66 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons'; import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda'; import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react'; import React, { useState } from 'react';
import { Collapse, FormGroup, Input } from 'reactstrap'; import { Collapse, FormGroup, Input } from 'reactstrap';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput'; import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox'; import Checkbox from '../utils/Checkbox';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils'; import { versionMatch } from '../utils/helpers/version';
import { hasValue } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks';
import { createShortUrlResultType } from './reducers/shortUrlCreation'; import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format(); const formatDate = (date) => isNil(date) ? date : date.format();
const CreateShortUrl = ( const propTypes = {
TagsSelector, createShortUrl: PropTypes.func,
CreateShortUrlResult, shortUrlCreationResult: createShortUrlResultType,
ForServerVersion resetCreateShortUrl: PropTypes.func,
) => class CreateShortUrl extends React.Component { selectedServer: serverType,
static propTypes = { };
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
selectedServer: serverType,
};
state = { const initialState = {
longUrl: '', longUrl: '',
tags: [], tags: [],
customSlug: undefined, customSlug: '',
domain: undefined, shortCodeLength: '',
validSince: undefined, domain: '',
validUntil: undefined, validSince: undefined,
maxVisits: undefined, validUntil: undefined,
findIfExists: false, maxVisits: '',
moreOptionsVisible: false, findIfExists: false,
}; };
render() { const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props; const CreateShortUrlComp = ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }) => {
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) }); const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlCreation(initialState);
const save = (e) => {
e.preventDefault();
const shortUrlData = {
...shortUrlCreation,
validSince: formatDate(shortUrlCreation.validSince),
validUntil: formatDate(shortUrlCreation.validUntil),
};
createShortUrl(shortUrlData).then(reset).catch(() => {});
};
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => ( const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<FormGroup> <FormGroup>
<Input <Input
id={id} id={id}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
value={this.state[id]} value={shortUrlCreation[id]}
onChange={(e) => this.setState({ [id]: e.target.value })} onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
{...props} {...props}
/> />
</FormGroup> </FormGroup>
@@ -57,105 +68,106 @@ const CreateShortUrl = (
const renderDateInput = (id, placeholder, props = {}) => ( const renderDateInput = (id, placeholder, props = {}) => (
<div className="form-group"> <div className="form-group">
<DateInput <DateInput
selected={this.state[id]} selected={shortUrlCreation[id]}
placeholderText={placeholder} placeholderText={placeholder}
isClearable isClearable
onChange={(date) => this.setState({ [id]: date })} onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
{...props} {...props}
/> />
</div> </div>
); );
const save = (e) => {
e.preventDefault(); const currentServerVersion = selectedServer && selectedServer.version;
createShortUrl(pipe( const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
dissoc('moreOptionsVisible'), const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
assoc('validSince', formatDate(this.state.validSince)),
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 ( return (
<div className="shlink-container"> <form onSubmit={save}>
<form onSubmit={save}> <div className="form-group">
<input
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={shortUrlCreation.longUrl}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
/>
</div>
<Collapse isOpen={moreOptionsVisible}>
<div className="form-group"> <div className="form-group">
<input <TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={this.state.longUrl}
onChange={(e) => this.setState({ longUrl: e.target.value })}
/>
</div> </div>
<Collapse isOpen={this.state.moreOptionsVisible}> <div className="row">
<div className="form-group"> <div className="col-sm-4">
<TagsSelector tags={this.state.tags} onChange={changeTags} /> {renderOptionalInput('customSlug', 'Custom slug')}
</div> </div>
<div className="col-sm-4">
<div className="row"> {renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
<div className="col-sm-6"> min: 4,
{renderOptionalInput('customSlug', 'Custom slug')} disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
</div> ...disableShortCodeLength && {
<div className="col-sm-6"> title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
{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>
<div className="col-sm-4">
<div className="row"> {renderOptionalInput('domain', 'Domain', 'text', {
<div className="col-sm-6"> disabled: disableDomain,
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} ...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
</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> </div>
<ForServerVersion minVersion="1.16.0">
<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>
</ForServerVersion>
</Collapse>
<div>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)}
>
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button>
</div> </div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} /> <div className="row">
</form> <div className="col-sm-4">
</div> {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-4">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
</div>
<div className="col-sm-4">
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
</div>
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForServerVersion>
</Collapse>
<div>
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
</form>
); );
} };
CreateShortUrlComp.propTypes = propTypes;
return CreateShortUrlComp;
}; };
export default CreateShortUrl; export default CreateShortUrl;

View File

@@ -2,7 +2,8 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { rangeOf } from '../utils/utils'; import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
import './Paginator.scss';
const propTypes = { const propTypes = {
serverId: PropTypes.string.isRequired, serverId: PropTypes.string.isRequired,
@@ -12,7 +13,7 @@ const propTypes = {
}), }),
}; };
export default function Paginator({ paginator = {}, serverId }) { const Paginator = ({ paginator = {}, serverId }) => {
const { currentPage, pagesCount = 0 } = paginator; const { currentPage, pagesCount = 0 } = paginator;
if (pagesCount <= 1) { if (pagesCount <= 1) {
@@ -20,8 +21,12 @@ export default function Paginator({ paginator = {}, serverId }) {
} }
const renderPages = () => const renderPages = () =>
rangeOf(pagesCount, (pageNumber) => ( progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
<PaginationItem key={pageNumber} active={currentPage === pageNumber}> <PaginationItem
key={keyForPage(pageNumber, index)}
disabled={isPageDisabled(pageNumber)}
active={currentPage === pageNumber}
>
<PaginationLink <PaginationLink
tag={Link} tag={Link}
to={`/server/${serverId}/list-short-urls/${pageNumber}`} to={`/server/${serverId}/list-short-urls/${pageNumber}`}
@@ -32,7 +37,7 @@ export default function Paginator({ paginator = {}, serverId }) {
)); ));
return ( return (
<Pagination listClassName="flex-wrap"> <Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
<PaginationItem disabled={currentPage === 1}> <PaginationItem disabled={currentPage === 1}>
<PaginationLink <PaginationLink
previous previous
@@ -50,6 +55,8 @@ export default function Paginator({ paginator = {}, serverId }) {
</PaginationItem> </PaginationItem>
</Pagination> </Pagination>
); );
} };
Paginator.propTypes = propTypes; Paginator.propTypes = propTypes;
export default Paginator;

View File

@@ -0,0 +1,7 @@
.short-urls-paginator {
position: sticky;
bottom: 0;
background-color: rgba(white, .8);
padding: .75rem 0;
border-top: 1px solid rgba(black, .125);
}

View File

@@ -7,7 +7,7 @@ import moment from 'moment';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag'; import Tag from '../tags/helpers/Tag';
import DateRangeRow from '../utils/DateRangeRow'; import DateRangeRow from '../utils/DateRangeRow';
import { formatDate } from '../utils/utils'; import { formatDate } from '../utils/helpers/date';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss'; import './SearchBar.scss';
@@ -36,12 +36,16 @@ const SearchBar = (colorGenerator, ForServerVersion) => {
<ForServerVersion minVersion="1.21.0"> <ForServerVersion minVersion="1.21.0">
<div className="mt-3"> <div className="mt-3">
<DateRangeRow <div className="row">
startDate={dateOrUndefined(shortUrlsListParams.startDate)} <div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
endDate={dateOrUndefined(shortUrlsListParams.endDate)} <DateRangeRow
onStartDateChange={setDate('startDate')} startDate={dateOrUndefined(shortUrlsListParams.startDate)}
onEndDateChange={setDate('endDate')} endDate={dateOrUndefined(shortUrlsListParams.endDate)}
/> onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</div>
</div>
</div> </div>
</ForServerVersion> </ForServerVersion>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Paginator from './Paginator'; import Paginator from './Paginator';
@@ -14,16 +14,22 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
const { match: { params }, shortUrlsList } = props; const { match: { params }, shortUrlsList } = props;
const { page, serverId } = params; const { page, serverId } = params;
const { data = [], pagination } = shortUrlsList; const { data = [], pagination } = shortUrlsList;
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
// Using a key on a component makes react to create a new instance every time the key changes // Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${serverId}_${page}`; // Without it, pagination on the URL will not make the component to be refreshed
useEffect(() => {
setUrlsListKey(`${serverId}_${page}`);
}, [ serverId, page ]);
return ( return (
<div className="shlink-container"> <React.Fragment>
<div className="form-group"><SearchBar /></div> <div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} /> <div>
<Paginator paginator={pagination} serverId={serverId} /> <ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
</div> <Paginator paginator={pagination} serverId={serverId} />
</div>
</React.Fragment>
); );
}; };

View File

@@ -112,9 +112,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
return shortUrlsList.map((shortUrl) => ( return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow <ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
key={shortUrl.shortCode}
refreshList={this.refreshList} refreshList={this.refreshList}
shortUrlsListParams={shortUrlsListParams} shortUrlsListParams={shortUrlsListParams}
/> />
@@ -161,7 +161,7 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
className="short-urls-list__header-cell short-urls-list__header-cell--with-action" className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('visits')} onClick={this.orderByColumn('visits')}
> >
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span> <span className="indivisible">{this.renderOrderIcon('visits')} Visits</span>
</th> </th>
<th className="short-urls-list__header-cell">&nbsp;</th> <th className="short-urls-list__header-cell">&nbsp;</th>
</tr> </tr>

View File

@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { Modal, ModalBody, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './UseExistingIfFoundInfoIcon.scss'; import './UseExistingIfFoundInfoIcon.scss';
import { useToggle } from '../utils/utils'; import { useToggle } from '../utils/helpers/hooks';
const renderInfoModal = (isOpen, toggle) => ( const renderInfoModal = (isOpen, toggle) => (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg"> <Modal isOpen={isOpen} toggle={toggle} centered size="lg">
@@ -38,7 +38,7 @@ const renderInfoModal = (isOpen, toggle) => (
); );
const UseExistingIfFoundInfoIcon = () => { const UseExistingIfFoundInfoIcon = () => {
const [ isModalOpen, toggleModal ] = useToggle(false); const [ isModalOpen, toggleModal ] = useToggle();
return ( return (
<React.Fragment> <React.Fragment>

View File

@@ -9,7 +9,7 @@ import { isEmpty, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta'; import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput'; import DateInput from '../../utils/DateInput';
import { formatIsoDate } from '../../utils/utils'; import { formatIsoDate } from '../../utils/helpers/date';
const propTypes = { const propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
@@ -26,9 +26,7 @@ const dateOrUndefined = (shortUrl, dateName) => {
return date && moment(date); return date && moment(date);
}; };
const EditMetaModal = ( const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => {
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }
) => {
const { saving, error } = shortUrlMeta; const { saving, error } = shortUrlMeta;
const url = shortUrl && (shortUrl.shortUrl || ''); const url = shortUrl && (shortUrl.shortUrl || '');
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince')); const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));

View File

@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { shortUrlType } from '../reducers/shortUrlsList';
import { ShortUrlEditionType } from '../reducers/shortUrlEdition';
import { hasValue } from '../../utils/utils';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlEdition: ShortUrlEditionType,
editShortUrl: PropTypes.func,
};
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }) => {
const { saving, error } = shortUrlEdition;
const url = shortUrl && (shortUrl.shortUrl || '');
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
const doEdit = () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
Edit long URL for <ExternalLink href={url} />
</ModalHeader>
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
<ModalBody>
<FormGroup className="mb-0">
<Input
type="url"
required
placeholder="Long URL"
value={longUrl}
onChange={(e) => setLongUrl(e.target.value)}
/>
</FormGroup>
{error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the long URL :(
</div>
)}
</ModalBody>
<ModalFooter>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="primary" disabled={saving || !hasValue(longUrl)}>{saving ? 'Saving...' : 'Save'}</Button>
</ModalFooter>
</form>
</Modal>
);
};
EditShortUrlModal.propTypes = propTypes;
export default EditShortUrlModal;

View File

@@ -3,6 +3,9 @@ import React from 'react';
import Moment from 'react-moment'; import Moment from 'react-moment';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
@@ -10,53 +13,57 @@ import Tag from '../../tags/helpers/Tag';
import ShortUrlVisitsCount from './ShortUrlVisitsCount'; import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import './ShortUrlsRow.scss'; import './ShortUrlsRow.scss';
const propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType,
shortUrl: shortUrlType,
};
const ShortUrlsRow = ( const ShortUrlsRow = (
ShortUrlsRowMenu, ShortUrlsRowMenu,
colorGenerator, colorGenerator,
stateFlagTimeout useStateFlagTimeout
) => class ShortUrlsRow extends React.Component { ) => {
static propTypes = { const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
refreshList: PropTypes.func, const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(false);
shortUrlsListParams: shortUrlsListParamsType, const renderTags = (tags) => {
selectedServer: serverType, if (isEmpty(tags)) {
shortUrl: shortUrlType, return <i className="indivisible"><small>No tags</small></i>;
}; }
state = { copiedToClipboard: false }; const selectedTags = shortUrlsListParams.tags || [];
renderTags(tags) { return tags.map((tag) => (
if (isEmpty(tags)) { <Tag
return <i className="nowrap"><small>No tags</small></i>; colorGenerator={colorGenerator}
} key={tag}
text={tag}
const { refreshList, shortUrlsListParams } = this.props; onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
const selectedTags = shortUrlsListParams.tags || []; />
));
return tags.map((tag) => ( };
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
/>
));
}
render() {
const { shortUrl, selectedServer } = this.props;
return ( return (
<tr className="short-urls-row"> <tr className="short-urls-row">
<td className="nowrap short-urls-row__cell" data-th="Created at: "> <td className="indivisible short-urls-row__cell" data-th="Created at: ">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment> <Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td> </td>
<td className="short-urls-row__cell" data-th="Short URL: "> <td className="short-urls-row__cell" data-th="Short URL: ">
<ExternalLink href={shortUrl.shortUrl} /> <span className="indivisible short-urls-row__cell--relative">
<ExternalLink href={shortUrl.shortUrl} />
<CopyToClipboard text={shortUrl.shortUrl} onCopy={setCopiedToClipboard}>
<FontAwesomeIcon icon={copyIcon} className="ml-2 short-urls-row__copy-btn" />
</CopyToClipboard>
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
Copied short URL!
</span>
</span>
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: "> <td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<ExternalLink href={shortUrl.longUrl} /> <ExternalLink href={shortUrl.longUrl} />
</td> </td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td> <td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: "> <td className="short-urls-row__cell text-md-right" data-th="Visits: ">
<ShortUrlVisitsCount <ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount} visitsCount={shortUrl.visitsCount}
@@ -64,22 +71,16 @@ const ShortUrlsRow = (
selectedServer={selectedServer} selectedServer={selectedServer}
/> />
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--relative"> <td className="short-urls-row__cell">
<small <ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
className="badge badge-warning short-urls-row__copy-hint"
hidden={!this.state.copiedToClipboard}
>
Copied short URL!
</small>
<ShortUrlsRowMenu
selectedServer={selectedServer}
shortUrl={shortUrl}
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}
/>
</td> </td>
</tr> </tr>
); );
} };
ShortUrlsRowComp.propTypes = propTypes;
return ShortUrlsRowComp;
}; };
export default ShortUrlsRow; export default ShortUrlsRow;

View File

@@ -43,11 +43,16 @@
position: relative; position: relative;
} }
.short-urls-row__copy-btn {
cursor: pointer;
font-size: 1.2rem;
}
.short-urls-row__copy-hint { .short-urls-row__copy-hint {
@include vertical-align(); @include vertical-align(translateX(10px));
right: 100%; box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
@media (max-width: $smMax) { @media (max-width: $smMax) {
right: calc(100% + 10px); @include vertical-align(translateX(calc(-100% - 20px)));
} }
} }

View File

@@ -1,4 +1,4 @@
import { faCopy as copyIcon, faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons'; import { faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
import { import {
faTags as tagsIcon, faTags as tagsIcon,
faChartPie as pieChartIcon, faChartPie as pieChartIcon,
@@ -6,53 +6,37 @@ import {
faQrcode as qrIcon, faQrcode as qrIcon,
faMinusCircle as deleteIcon, faMinusCircle as deleteIcon,
faEdit as editIcon, faEdit as editIcon,
faLink as linkIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import { useToggle } from '../../utils/helpers/hooks';
import PreviewModal from './PreviewModal'; import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal'; import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink'; import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss'; import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = ( const propTypes = {
DeleteShortUrlModal, selectedServer: serverType,
EditTagsModal, shortUrl: shortUrlType,
EditMetaModal, };
ForServerVersion
) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
shortUrl: shortUrlType,
};
state = { const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal, EditMetaModal, EditShortUrlModal, ForServerVersion) => {
isOpen: false, const ShortUrlsRowMenuComp = ({ shortUrl, selectedServer }) => {
isQrModalOpen: false, const [ isOpen, toggle ] = useToggle();
isPreviewModalOpen: false, const [ isQrModalOpen, toggleQrCode ] = useToggle();
isTagsModalOpen: false, const [ isPreviewModalOpen, togglePreview ] = useToggle();
isMetaModalOpen: false, const [ isTagsModalOpen, toggleTags ] = useToggle();
isDeleteModalOpen: false, const [ isMetaModalOpen, toggleMeta ] = useToggle();
}; const [ isDeleteModalOpen, toggleDelete ] = useToggle();
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen })); const [ isEditModalOpen, toggleEdit ] = useToggle();
render() {
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewModalOpen');
const toggleTags = toggleModal('isTagsModalOpen');
const toggleMeta = toggleModal('isMetaModalOpen');
const toggleDelete = toggleModal('isDeleteModalOpen');
return ( return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}> <ButtonDropdown toggle={toggle} isOpen={isOpen}>
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle"> <DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp; &nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle> </DropdownToggle>
@@ -64,47 +48,48 @@ const ShortUrlsRowMenu = (
<DropdownItem onClick={toggleTags}> <DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags <FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
</DropdownItem> </DropdownItem>
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} /> <EditTagsModal shortUrl={shortUrl} isOpen={isTagsModalOpen} toggle={toggleTags} />
<ForServerVersion minVersion="1.18.0"> <ForServerVersion minVersion="1.18.0">
<DropdownItem onClick={toggleMeta}> <DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata <FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem> </DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} /> <EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
</ForServerVersion> </ForServerVersion>
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}> <ForServerVersion minVersion="2.1.0">
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL <DropdownItem onClick={toggleEdit}>
</DropdownItem> <FontAwesomeIcon icon={linkIcon} fixedWidth /> Edit long URL
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
<DropdownItem divider />
<ForServerVersion maxVersion="1.x">
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem> </DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} /> <EditShortUrlModal shortUrl={shortUrl} isOpen={isEditModalOpen} toggle={toggleEdit} />
</ForServerVersion> </ForServerVersion>
<DropdownItem onClick={toggleQrCode}> <DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code <FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem> </DropdownItem>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} /> <QrCodeModal url={completeShortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
<ForServerVersion maxVersion="1.x"> <ForServerVersion maxVersion="1.x">
<DropdownItem divider /> <DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={isPreviewModalOpen} toggle={togglePreview} />
</ForServerVersion> </ForServerVersion>
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}> <DropdownItem divider />
<DropdownItem>
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard <DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
</DropdownItem> <FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</CopyToClipboard> </DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
</DropdownMenu> </DropdownMenu>
</ButtonDropdown> </ButtonDropdown>
); );
} };
ShortUrlsRowMenuComp.propTypes = propTypes;
return ShortUrlsRowMenuComp;
}; };
export default ShortUrlsRowMenu; export default ShortUrlsRowMenu;

View File

@@ -31,8 +31,7 @@ export default handleActions({
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => { export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
dispatch({ type: CREATE_SHORT_URL_START }); dispatch({ type: CREATE_SHORT_URL_START });
const { createShortUrl } = buildShlinkApiClient(getState);
const { createShortUrl } = await buildShlinkApiClient(getState);
try { try {
const result = await createShortUrl(data); const result = await createShortUrl(data);
@@ -40,6 +39,8 @@ export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatc
dispatch({ type: CREATE_SHORT_URL, result }); dispatch({ type: CREATE_SHORT_URL, result });
} catch (e) { } catch (e) {
dispatch({ type: CREATE_SHORT_URL_ERROR }); dispatch({ type: CREATE_SHORT_URL_ERROR });
throw e;
} }
}; };

View File

@@ -32,8 +32,7 @@ export default handleActions({
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => { export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START }); dispatch({ type: DELETE_SHORT_URL_START });
const { deleteShortUrl } = buildShlinkApiClient(getState);
const { deleteShortUrl } = await buildShlinkApiClient(getState);
try { try {
await deleteShortUrl(shortCode, domain); await deleteShortUrl(shortCode, domain);

View File

@@ -0,0 +1,42 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
/* eslint-enable padding-line-between-statements */
export const ShortUrlEditionType = PropTypes.shape({
shortCode: PropTypes.string,
longUrl: PropTypes.string,
saving: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
});
const initialState = {
shortCode: null,
longUrl: null,
saving: false,
error: false,
};
export default handleActions({
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[SHORT_URL_EDITED]: (state, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }),
}, initialState);
export const editShortUrl = (buildShlinkApiClient) => (shortCode, domain, longUrl) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_START });
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, { longUrl });
dispatch({ shortCode, longUrl, domain, type: SHORT_URL_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_ERROR });
throw e;
}
};

View File

@@ -37,7 +37,7 @@ export default handleActions({
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => { export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_META_START }); dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState); const { updateShortUrlMeta } = buildShlinkApiClient(getState);
try { try {
await updateShortUrlMeta(shortCode, domain, meta); await updateShortUrlMeta(shortCode, domain, meta);

View File

@@ -31,7 +31,7 @@ export default handleActions({
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => { export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START }); dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { updateShortUrlTags } = await buildShlinkApiClient(getState); const { updateShortUrlTags } = buildShlinkApiClient(getState);
try { try {
const normalizedTags = await updateShortUrlTags(shortCode, domain, tags); const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
import { SHORT_URL_EDITED } from './shortUrlEdition';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@@ -54,12 +55,12 @@ export default handleActions({
), ),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
}, initialState); }, initialState);
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => { export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START }); dispatch({ type: LIST_SHORT_URLS_START });
const { listShortUrls } = buildShlinkApiClient(getState);
const { listShortUrls } = await buildShlinkApiClient(getState);
try { try {
const shortUrls = await listShortUrls(params); const shortUrls = await listShortUrls(params);

View File

@@ -9,6 +9,7 @@ import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal'; import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal'; import EditTagsModal from '../helpers/EditTagsModal';
import EditMetaModal from '../helpers/EditMetaModal'; import EditMetaModal from '../helpers/EditMetaModal';
import EditShortUrlModal from '../helpers/EditShortUrlModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult'; import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList'; import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
@@ -16,6 +17,7 @@ import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletio
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags'; import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta'; import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
import { editShortUrl } from '../reducers/shortUrlEdition';
const provideServices = (bottle, connect) => { const provideServices = (bottle, connect) => {
// Components // Components
@@ -33,7 +35,7 @@ const provideServices = (bottle, connect) => {
[ 'listShortUrls', 'resetShortUrlParams' ] [ 'listShortUrls', 'resetShortUrlParams' ]
)); ));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
bottle.serviceFactory( bottle.serviceFactory(
'ShortUrlsRowMenu', 'ShortUrlsRowMenu',
@@ -41,6 +43,7 @@ const provideServices = (bottle, connect) => {
'DeleteShortUrlModal', 'DeleteShortUrlModal',
'EditTagsModal', 'EditTagsModal',
'EditMetaModal', 'EditMetaModal',
'EditShortUrlModal',
'ForServerVersion' 'ForServerVersion'
); );
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
@@ -60,6 +63,9 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('EditMetaModal', () => EditMetaModal); bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ])); bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
// Actions // Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags); bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
@@ -75,6 +81,8 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta); bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
}; };
export default provideServices; export default provideServices;

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { splitEvery } from 'ramda'; import { splitEvery } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage'; import Message from '../utils/Message';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
const { ceil } = Math; const { ceil } = Math;
@@ -29,7 +29,7 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
const { tagsList, match } = this.props; const { tagsList, match } = this.props;
if (tagsList.loading) { if (tagsList.loading) {
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>; return <Message noMargin loading />;
} }
if (tagsList.error) { if (tagsList.error) {
@@ -43,7 +43,7 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
const tagsCount = tagsList.filteredTags.length; const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) { if (tagsCount < 1) {
return <MuttedMessage>No tags found</MuttedMessage>; return <Message>No tags found</Message>;
} }
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags); const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
@@ -69,14 +69,14 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
const { filterTags } = this.props; const { filterTags } = this.props;
return ( return (
<div className="shlink-container"> <React.Fragment>
{!this.props.tagsList.loading && {!this.props.tagsList.loading &&
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} /> <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
} }
<div className="row"> <div className="row">
{this.renderContent()} {this.renderContent()}
</div> </div>
</div> </React.Fragment>
); );
} }
}; };

View File

@@ -26,8 +26,7 @@ export default handleActions({
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => { export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
dispatch({ type: DELETE_TAG_START }); dispatch({ type: DELETE_TAG_START });
const { deleteTags } = buildShlinkApiClient(getState);
const { deleteTags } = await buildShlinkApiClient(getState);
try { try {
await deleteTags([ tag ]); await deleteTags([ tag ]);

View File

@@ -31,8 +31,7 @@ export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newNa
getState getState
) => { ) => {
dispatch({ type: EDIT_TAG_START }); dispatch({ type: EDIT_TAG_START });
const { editTag } = buildShlinkApiClient(getState);
const { editTag } = await buildShlinkApiClient(getState);
try { try {
await editTag(oldName, newName); await editTag(oldName, newName);

View File

@@ -50,7 +50,7 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
dispatch({ type: LIST_TAGS_START }); dispatch({ type: LIST_TAGS_START });
try { try {
const { listTags } = await buildShlinkApiClient(getState); const { listTags } = buildShlinkApiClient(getState);
const tags = await listTags(); const tags = await listTags();
dispatch({ tags, type: LIST_TAGS }); dispatch({ tags, type: LIST_TAGS });

View File

@@ -12,6 +12,7 @@ const propTypes = {
isClearable: PropTypes.bool, isClearable: PropTypes.bool,
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]), selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
ref: PropTypes.object, ref: PropTypes.object,
disabled: PropTypes.bool,
}; };
const DateInput = (props) => { const DateInput = (props) => {

View File

@@ -7,6 +7,9 @@
.date-input-container__input { .date-input-container__input {
padding-right: 35px !important; padding-right: 35px !important;
}
.date-input-container__input:not(:disabled) {
background-color: #fff !important; background-color: #fff !important;
} }

View File

@@ -9,26 +9,29 @@ const propTypes = {
endDate: dateType, endDate: dateType,
onStartDateChange: PropTypes.func.isRequired, onStartDateChange: PropTypes.func.isRequired,
onEndDateChange: PropTypes.func.isRequired, onEndDateChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
}; };
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => ( const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange, disabled = false }) => (
<div className="row"> <div className="row">
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4"> <div className="col-md-6">
<DateInput <DateInput
selected={startDate} selected={startDate}
placeholderText="Since" placeholderText="Since"
isClearable isClearable
maxDate={endDate} maxDate={endDate}
disabled={disabled}
onChange={onStartDateChange} onChange={onStartDateChange}
/> />
</div> </div>
<div className="col-xl-3 col-lg-4 col-md-6"> <div className="col-md-6">
<DateInput <DateInput
className="date-range-row__date-input" className="date-range-row__date-input"
selected={endDate} selected={endDate}
placeholderText="Until" placeholderText="Until"
isClearable isClearable
minDate={startDate} minDate={startDate}
disabled={disabled}
onChange={onEndDateChange} onChange={onEndDateChange}
/> />
</div> </div>

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
const propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
id: PropTypes.string,
type: PropTypes.string,
required: PropTypes.bool,
};
export const HorizontalFormGroup = ({ children, value, onChange, id = uuid(), type = 'text', required = true }) => (
<div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
{children}:
</label>
<div className="col-lg-11 col-md-10">
<input
className="form-control"
type={type}
id={id}
value={value}
required={required}
onChange={(e) => onChange(e.target.value)}
/>
</div>
</div>
);
HorizontalFormGroup.propTypes = propTypes;

48
src/utils/Message.js Normal file
View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Card } from 'reactstrap';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const getClassForType = (type) => {
const map = {
error: 'border-danger',
};
return map[type] || '';
};
const getTextClassForType = (type) => {
const map = {
error: 'text-danger',
};
return map[type] || 'text-muted';
};
const propTypes = {
noMargin: PropTypes.bool,
loading: PropTypes.bool,
children: PropTypes.node,
type: PropTypes.oneOf([ 'default', 'error' ]),
};
const Message = ({ children, loading = false, noMargin = false, type = 'default' }) => {
const cardClasses = classNames('bg-light', getClassForType(type), { 'mt-4': !noMargin });
return (
<div className="col-md-10 offset-md-1">
<Card className={cardClasses} body>
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
{loading && <FontAwesomeIcon icon={preloader} spin />}
{loading && <span className="ml-2">{children || 'Loading...'}</span>}
{!loading && children}
</h3>
</Card>
</div>
);
};
Message.propTypes = propTypes;
export default Message;

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { Card } from 'reactstrap';
import classnames from 'classnames';
import PropTypes from 'prop-types';
const DEFAULT_MARGIN_SIZE = 4;
const propTypes = {
marginSize: PropTypes.number,
children: PropTypes.node,
};
export default function MutedMessage({ children, marginSize = DEFAULT_MARGIN_SIZE }) {
const cardClasses = classnames('bg-light', {
[`mt-${marginSize}`]: marginSize > 0,
});
return (
<div className="col-md-10 offset-md-1">
<Card className={cardClasses} body>
<h3 className="text-center text-muted mb-0">
{children}
</h3>
</Card>
</div>
);
}
MutedMessage.propTypes = propTypes;

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons'; import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@@ -6,62 +6,59 @@ import classNames from 'classnames';
import './SearchField.scss'; import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500; const DEFAULT_SEARCH_INTERVAL = 500;
let timer;
export default class SearchField extends React.Component { const propTypes = {
static propTypes = { onChange: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired, className: PropTypes.string,
className: PropTypes.string, placeholder: PropTypes.string,
placeholder: PropTypes.string, large: PropTypes.bool,
noBorder: PropTypes.bool,
};
const SearchField = ({ onChange, className, placeholder = 'Search...', large = true, noBorder = false }) => {
const [ searchTerm, setSearchTerm ] = useState('');
const resetTimer = () => {
clearTimeout(timer);
timer = null;
}; };
static defaultProps = { const searchTermChanged = (newSearchTerm, timeout = DEFAULT_SEARCH_INTERVAL) => {
className: '', setSearchTerm(newSearchTerm);
placeholder: 'Search...',
};
state = { showClearBtn: false, searchTerm: '' };
timer = null;
searchTermChanged(searchTerm, timeout = DEFAULT_SEARCH_INTERVAL) {
this.setState({
showClearBtn: searchTerm !== '',
searchTerm,
});
const resetTimer = () => {
clearTimeout(this.timer);
this.timer = null;
};
resetTimer(); resetTimer();
this.timer = setTimeout(() => { timer = setTimeout(() => {
this.props.onChange(searchTerm); onChange(newSearchTerm);
resetTimer(); resetTimer();
}, timeout); }, timeout);
} };
render() { return (
const { className, placeholder } = this.props; <div className={classNames('search-field', className)}>
<input
return ( type="text"
<div className={classNames('search-field', className)}> className={classNames('form-control search-field__input', {
<input 'form-control-lg': large,
type="text" 'search-field__input--no-border': noBorder,
className="form-control form-control-lg search-field__input" })}
placeholder={placeholder} placeholder={placeholder}
value={this.state.searchTerm} value={searchTerm}
onChange={(e) => this.searchTermChanged(e.target.value)} onChange={(e) => searchTermChanged(e.target.value)}
/> />
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" /> <FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
<div <div
className="close search-field__close" className="close search-field__close"
hidden={!this.state.showClearBtn} hidden={searchTerm === ''}
id="search-field__close" id="search-field__close"
onClick={() => this.searchTermChanged('', 0)} onClick={() => searchTermChanged('', 0)}
> >
&times; &times;
</div>
</div> </div>
); </div>
} );
} };
SearchField.propTypes = propTypes;
export default SearchField;

View File

@@ -2,6 +2,10 @@
.search-field { .search-field {
position: relative; position: relative;
&:focus-within {
z-index: 1;
}
} }
.search-field__input.search-field__input { .search-field__input.search-field__input {
@@ -9,6 +13,11 @@
padding-right: 40px; padding-right: 40px;
} }
.search-field__input--no-border.search-field__input--no-border {
border: none;
border-radius: 0;
}
.search-field__icon { .search-field__icon {
@include vertical-align(); @include vertical-align();

View File

@@ -13,6 +13,7 @@ $mainColor: #4696e5;
$lightHoverColor: #eee; $lightHoverColor: #eee;
$lightGrey: #ddd; $lightGrey: #ddd;
$dangerColor: #dc3545; $dangerColor: #dc3545;
$mediumGrey: #dee2e6;
// Misc // Misc
$headerHeight: 57px; $headerHeight: 57px;

View File

@@ -0,0 +1,3 @@
export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
export const formatIsoDate = (date) => date && date.format ? date.format() : date;

View File

@@ -0,0 +1,20 @@
import { useState } from 'react';
const DEFAULT_TIMEOUT_DELAY = 2000;
export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
const [ flag, setFlag ] = useState(initialValue);
const callback = () => {
setFlag(!initialValue);
setTimeout(() => setFlag(initialValue), delay);
};
return [ flag, callback ];
};
// Return [ flag, toggle, enable, disable ]
export const useToggle = (initialValue = false) => {
const [ flag, setFlag ] = useState(initialValue);
return [ flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false) ];
};

View File

@@ -0,0 +1,8 @@
const TEN_ROUNDING_NUMBER = 10;
const { ceil } = Math;
const formatter = new Intl.NumberFormat('en-US');
export const prettify = (number) => formatter.format(number);
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;

View File

@@ -0,0 +1,27 @@
import { max, min, range } from 'ramda';
export const ELLIPSIS = '...';
export const progressivePagination = (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;
};
export const keyForPage = (pageNumber, index) => pageNumber !== ELLIPSIS ? pageNumber : `${pageNumber}_${index}`;
export const isPageDisabled = (pageNumber) => pageNumber === ELLIPSIS;

View File

@@ -0,0 +1,27 @@
import { compare } from 'compare-versions';
import { identity, memoizeWith } from 'ramda';
import { hasValue } from '../utils';
export const versionMatch = (versionToMatch, { maxVersion, minVersion }) => {
if (!hasValue(versionToMatch)) {
return false;
}
const matchesMinVersion = !minVersion || compare(versionToMatch, minVersion, '>=');
const matchesMaxVersion = !maxVersion || compare(versionToMatch, maxVersion, '<=');
return !!(matchesMaxVersion && matchesMinVersion);
};
const versionIsValidSemVer = memoizeWith(identity, (version) => {
try {
return compare(version, version, '=');
} catch (e) {
return false;
}
});
export const versionToPrintable = (version) => !versionIsValidSemVer(version) ? version : `v${version}`;
export const versionToSemVer = (defaultValue = 'latest') =>
(version) => versionIsValidSemVer(version) ? version : defaultValue;

View File

@@ -0,0 +1,37 @@
import bowser from 'bowser';
import { hasValue } from '../utils';
const DEFAULT = 'Others';
const BROWSERS_WHITELIST = [
'Android Browser',
'Chrome',
'Chromium',
'Firefox',
'Internet Explorer',
'Microsoft Edge',
'Opera',
'Safari',
'Samsung Internet for Android',
'Vivaldi',
'WeChat',
];
export const parseUserAgent = (userAgent) => {
if (!hasValue(userAgent)) {
return { browser: DEFAULT, os: DEFAULT };
}
const { browser: { name: browser }, os: { name: os } } = bowser.parse(userAgent);
return { os: os || DEFAULT, browser: browser && BROWSERS_WHITELIST.includes(browser) ? browser : DEFAULT };
};
export const extractDomain = (url) => {
if (!hasValue(url)) {
return 'Direct';
}
const domain = url.includes('://') ? url.split('/')[2] : url.split('/')[0];
return domain.split(':')[0];
};

View File

@@ -0,0 +1,37 @@
@import "../base";
@mixin sticky-cell() {
z-index: 1;
border: none !important;
position: relative;
&:before {
content: '';
position: absolute;
top: -1px;
left: 0;
bottom: -1px;
right: -1px;
background: $mediumGrey;
z-index: -2;
}
&:first-child:before {
left: -1px;
}
&:after {
content: '';
position: absolute;
top: 0;
left: 1px;
bottom: 0;
right: 0;
background: white;
z-index: -1;
}
&:first-child:after {
left: 0;
}
}

View File

@@ -1,5 +1,5 @@
@mixin vertical-align { @mixin vertical-align($extraTransforms: null) {
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%) $extraTransforms;
} }

View File

@@ -1,21 +1,16 @@
import { wait } from '../utils';
import ShlinkApiClient from './ShlinkApiClient'; import ShlinkApiClient from './ShlinkApiClient';
const apiClients = {}; const apiClients = {};
const getSelectedServerFromState = async (getState) => { const getSelectedServerFromState = (getState) => {
const { selectedServer } = getState(); const { selectedServer } = getState();
if (!selectedServer) {
return wait(250).then(() => getSelectedServerFromState(getState));
}
return selectedServer; return selectedServer;
}; };
const buildShlinkApiClient = (axios) => async (getStateOrSelectedServer) => { const buildShlinkApiClient = (axios) => (getStateOrSelectedServer) => {
const { url, apiKey } = typeof getStateOrSelectedServer === 'function' const { url, apiKey } = typeof getStateOrSelectedServer === 'function'
? await getSelectedServerFromState(getStateOrSelectedServer) ? getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer; : getStateOrSelectedServer;
const clientKey = `${url}_${apiKey}`; const clientKey = `${url}_${apiKey}`;

View File

@@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { stateFlagTimeout } from '../utils'; import { stateFlagTimeout } from '../utils';
import { useStateFlagTimeout } from '../helpers/hooks';
import Storage from './Storage'; import Storage from './Storage';
import ColorGenerator from './ColorGenerator'; import ColorGenerator from './ColorGenerator';
import buildShlinkApiClient from './ShlinkApiClientBuilder'; import buildShlinkApiClient from './ShlinkApiClientBuilder';
@@ -14,6 +15,7 @@ const provideServices = (bottle) => {
bottle.constant('setTimeout', global.setTimeout); bottle.constant('setTimeout', global.setTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout'); bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout');
}; };
export default provideServices; export default provideServices;

View File

@@ -2,13 +2,9 @@ import L from 'leaflet';
import marker2x from 'leaflet/dist/images/marker-icon-2x.png'; import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png'; import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda'; import { isEmpty, isNil, range } from 'ramda';
import { useState } from 'react';
import { compare } from 'compare-versions';
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000; const DEFAULT_TIMEOUT_DELAY = 2000;
const { ceil } = Math;
export const stateFlagTimeout = (setTimeout) => ( export const stateFlagTimeout = (setTimeout) => (
setState, setState,
@@ -45,30 +41,4 @@ export const fixLeafletIcons = () => {
export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn); 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 hasValue = (value) => !isNil(value) && !isEmpty(value);
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;
}
};
export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
export const formatIsoDate = (date) => date && date.format ? date.format() : date;

View File

@@ -2,7 +2,7 @@ import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { keys, values } from 'ramda'; import { keys, values, zipObj } from 'ramda';
import './GraphCard.scss'; import './GraphCard.scss';
const propTypes = { const propTypes = {
@@ -11,43 +11,79 @@ const propTypes = {
isBarChart: PropTypes.bool, isBarChart: PropTypes.bool,
stats: PropTypes.object, stats: PropTypes.object,
max: PropTypes.number, max: PropTypes.number,
highlightedStats: PropTypes.object,
onClick: PropTypes.func,
}; };
const generateGraphData = (title, isBarChart, labels, data) => ({ const generateGraphData = (title, isBarChart, labels, data, highlightedData) => ({
labels, labels,
datasets: [ datasets: [
{ {
title, title,
label: highlightedData && 'Non-selected',
data, data,
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
'#97BBCD', '#97BBCD',
'#DCDCDC',
'#F7464A', '#F7464A',
'#46BFBD', '#46BFBD',
'#FDB45C', '#FDB45C',
'#949FB1', '#949FB1',
'#4D5360', '#57A773',
'#414066',
'#08B2E3',
'#B6C454',
'#DCDCDC',
'#463730',
], ],
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white', borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
borderWidth: 2, borderWidth: 2,
}, },
], highlightedData && {
title,
label: 'Selected',
data: highlightedData,
backgroundColor: 'rgba(247, 127, 40, 0.4)',
borderColor: '#F77F28',
borderWidth: 2,
},
].filter(Boolean),
}); });
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label; const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
const renderGraph = (title, isBarChart, stats, max) => { const determineHeight = (isBarChart, labels) => {
if (!isBarChart && labels.length > 8) {
return 200;
}
return isBarChart && labels.length > 20 ? labels.length * 8 : null;
};
const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) => {
const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0;
const Component = isBarChart ? HorizontalBar : Doughnut; const Component = isBarChart ? HorizontalBar : Doughnut;
const labels = keys(stats).map(dropLabelIfHidden); const labels = keys(stats).map(dropLabelIfHidden);
const data = values(stats); const data = values(!hasHighlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
if (acc[highlightedKey]) {
acc[highlightedKey] -= highlightedStats[highlightedKey];
}
return acc;
}, { ...stats }));
const highlightedData = hasHighlightedStats && values(
{ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }
);
const options = { const options = {
legend: isBarChart ? { display: false } : { position: 'right' }, legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart && { scales: isBarChart && {
xAxes: [ xAxes: [
{ {
ticks: { beginAtZero: true, max }, ticks: { beginAtZero: true, max },
stacked: true,
}, },
], ],
yAxes: [{ stacked: true }],
}, },
tooltips: { tooltips: {
intersect: !isBarChart, intersect: !isBarChart,
@@ -55,18 +91,38 @@ const renderGraph = (title, isBarChart, stats, max) => {
// Do not show tooltip on items with empty label when in a bar chart // Do not show tooltip on items with empty label when in a bar chart
filter: ({ yLabel }) => !isBarChart || yLabel !== '', filter: ({ yLabel }) => !isBarChart || yLabel !== '',
}, },
onHover: isBarChart && (({ target }, chartElement) => {
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
}),
}; };
const graphData = generateGraphData(title, isBarChart, labels, data); const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData);
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null; const height = determineHeight(isBarChart, labels);
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered // 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} />; return (
<Component
key={height}
data={graphData}
options={options}
height={height}
getElementAtEvent={([ chart ]) => {
if (!onClick || !chart) {
return;
}
const { _index, _chart: { data } } = chart;
const { labels } = data;
onClick(labels[_index]);
}}
/>
);
}; };
const GraphCard = ({ title, footer, isBarChart, stats, max }) => ( const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, onClick }) => (
<Card className="mt-4"> <Card className="mt-4">
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader> <CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max)}</CardBody> <CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, onClick)}</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>} {footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card> </Card>
); );

View File

@@ -1,72 +1,119 @@
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons'; import { isEmpty, propEq, values } from 'ramda';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import React, { useState, useEffect, useMemo } from 'react';
import { isEmpty, mapObjIndexed, values } from 'ramda'; import { Button, Card, Collapse } from 'reactstrap';
import React from 'react';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import qs from 'qs'; import qs from 'qs';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons';
import DateRangeRow from '../utils/DateRangeRow'; import DateRangeRow from '../utils/DateRangeRow';
import MutedMessage from '../utils/MuttedMessage'; import Message from '../utils/Message';
import { formatDate } from '../utils/utils'; import { formatDate } from '../utils/helpers/date';
import { useToggle } from '../utils/helpers/hooks';
import SortableBarGraph from './SortableBarGraph'; import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader'; import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard'; import GraphCard from './GraphCard';
import { shortUrlDetailType } from './reducers/shortUrlDetail'; import { shortUrlDetailType } from './reducers/shortUrlDetail';
import VisitsTable from './VisitsTable';
const ShortUrlVisits = ( const propTypes = {
{ processStatsFromVisits }, match: PropTypes.shape({
OpenMapModalBtn params: PropTypes.object,
) => class ShortUrlVisits extends React.PureComponent { }),
static propTypes = { location: PropTypes.shape({
match: PropTypes.shape({ search: PropTypes.string,
params: PropTypes.object, }),
}), getShortUrlVisits: PropTypes.func,
location: PropTypes.shape({ shortUrlVisits: shortUrlVisitsType,
search: PropTypes.string, getShortUrlDetail: PropTypes.func,
}), shortUrlDetail: shortUrlDetailType,
getShortUrlVisits: PropTypes.func, cancelGetShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType, matchMedia: PropTypes.func,
getShortUrlDetail: PropTypes.func, };
shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func,
};
state = { startDate: undefined, endDate: undefined }; const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
loadVisits = (loadDetail = false) => { if (!acc[highlightedVisit[prop]]) {
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props; acc[highlightedVisit[prop]] = 0;
}
acc[highlightedVisit[prop]] += 1;
return acc;
}, {});
const format = formatDate();
let selectedBar;
const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
const ShortUrlVisitsComp = ({
match,
location,
shortUrlVisits,
shortUrlDetail,
getShortUrlVisits,
getShortUrlDetail,
cancelGetShortUrlVisits,
matchMedia = window.matchMedia,
}) => {
const [ startDate, setStartDate ] = useState(undefined);
const [ endDate, setEndDate ] = useState(undefined);
const [ showTable, toggleTable ] = useToggle();
const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle();
const [ highlightedVisits, setHighlightedVisits ] = useState([]);
const [ isMobileDevice, setIsMobileDevice ] = useState(false);
const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches);
const setSelectedVisits = (selectedVisits) => {
selectedBar = undefined;
setHighlightedVisits(selectedVisits);
};
const highlightVisitsForProp = (prop) => (value) => {
const newSelectedBar = `${prop}_${value}`;
if (selectedBar === newSelectedBar) {
setHighlightedVisits([]);
selectedBar = undefined;
} else {
setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
selectedBar = newSelectedBar;
}
};
const { params } = match;
const { shortCode } = params; const { shortCode } = params;
const { startDate, endDate } = mapObjIndexed(formatDate(), this.state); const { search } = location;
const { domain } = qs.parse(search, { ignoreQueryPrefix: true }); const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations const { visits, loading, loadingLarge, error } = shortUrlVisits;
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`; const showTableControls = !loading && visits.length > 0;
getShortUrlVisits(shortCode, { startDate, endDate, domain }); const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
() => processStatsFromVisits(normalizedVisits),
[ normalizedVisits ]
);
const mapLocations = values(citiesForMap);
if (loadDetail) { const loadVisits = () =>
getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain });
useEffect(() => {
getShortUrlDetail(shortCode, domain); getShortUrlDetail(shortCode, domain);
} determineIsMobileDevice();
}; window.addEventListener('resize', determineIsMobileDevice);
componentDidMount() { return () => {
this.timeWhenMounted = new Date().getTime(); cancelGetShortUrlVisits();
this.loadVisits(true); window.removeEventListener('resize', determineIsMobileDevice);
} };
}, []);
componentWillUnmount() { useEffect(() => {
this.props.cancelGetShortUrlVisits(); loadVisits();
} }, [ startDate, endDate ]);
render() {
const { shortUrlVisits, shortUrlDetail } = this.props;
const renderVisitsContent = () => { const renderVisitsContent = () => {
const { visits, loading, loadingLarge, error } = shortUrlVisits;
if (loading) { if (loading) {
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> {message}</MutedMessage>; return <Message loading>{message}</Message>;
} }
if (error) { if (error) {
@@ -78,14 +125,9 @@ const ShortUrlVisits = (
} }
if (isEmpty(visits)) { if (isEmpty(visits)) {
return <MutedMessage>There are no visits matching current filter :(</MutedMessage>; return <Message>There are no visits matching current filter :(</Message>;
} }
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
{ id: this.memoizationId, visits }
);
const mapLocations = values(citiesForMap);
return ( return (
<div className="row"> <div className="row">
<div className="col-xl-4 col-lg-6"> <div className="col-xl-4 col-lg-6">
@@ -96,63 +138,115 @@ const ShortUrlVisits = (
</div> </div>
<div className="col-xl-4"> <div className="col-xl-4">
<SortableBarGraph <SortableBarGraph
title="Referrers"
stats={referrers} stats={referrers}
withPagination={false} withPagination={false}
title="Referrers" highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
sortingItems={{ sortingItems={{
name: 'Referrer name', name: 'Referrer name',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('referer')}
/> />
</div> </div>
<div className="col-lg-6"> <div className="col-lg-6">
<SortableBarGraph <SortableBarGraph
stats={countries}
title="Countries" title="Countries"
stats={countries}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
sortingItems={{ sortingItems={{
name: 'Country name', name: 'Country name',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('country')}
/> />
</div> </div>
<div className="col-lg-6"> <div className="col-lg-6">
<SortableBarGraph <SortableBarGraph
stats={cities}
title="Cities" title="Cities"
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
extraHeaderContent={(activeCities) => extraHeaderContent={(activeCities) =>
mapLocations.length > 0 && mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} /> <OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
} }
sortingItems={{ sortingItems={{
name: 'City name', name: 'City name',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('city')}
/> />
</div> </div>
</div> </div>
); );
}; };
const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
return ( return (
<div className="shlink-container"> <React.Fragment>
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} /> <VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
<section className="mt-4"> <section className="mt-4">
<DateRangeRow <div className="row flex-md-row-reverse">
startDate={this.state.startDate} <div className="col-lg-7 col-xl-6">
endDate={this.state.endDate} <DateRangeRow
onStartDateChange={setDate('startDate')} disabled={loading}
onEndDateChange={setDate('endDate')} startDate={startDate}
/> endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
{showTableControls && (
<span className={classNames({ row: isMobileDevice })}>
<span className={classNames({ 'col-6': isMobileDevice })}>
<Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
{showTable ? 'Hide' : 'Show'} table
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
</Button>
</span>
<span className={classNames({ 'col-6': isMobileDevice, 'ml-2': !isMobileDevice })}>
<Button
outline
disabled={highlightedVisits.length === 0}
block={isMobileDevice}
onClick={() => setSelectedVisits([])}
>
Reset selection
</Button>
</span>
</span>
)}
</div>
</div>
</section> </section>
{showTableControls && (
<Collapse
isOpen={showTable}
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
onEntered={setSticky}
onExiting={unsetSticky}
>
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isSticky={tableIsSticky}
/>
</Collapse>
)}
<section> <section>
{renderVisitsContent()} {renderVisitsContent()}
</section> </section>
</div> </React.Fragment>
); );
} };
ShortUrlVisitsComp.propTypes = propTypes;
return ShortUrlVisitsComp;
}; };
export default ShortUrlVisits; export default ShortUrlVisits;

View File

@@ -1,23 +1,27 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda'; import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import SortingDropdown from '../utils/SortingDropdown'; import SortingDropdown from '../utils/SortingDropdown';
import PaginationDropdown from '../utils/PaginationDropdown'; import PaginationDropdown from '../utils/PaginationDropdown';
import { rangeOf, roundTen } from '../utils/utils'; import { rangeOf } from '../utils/utils';
import { roundTen } from '../utils/helpers/numbers';
import SimplePaginator from '../common/SimplePaginator'; import SimplePaginator from '../common/SimplePaginator';
import GraphCard from './GraphCard'; import GraphCard from './GraphCard';
const { max } = Math; const { max } = Math;
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value; const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickKeyFromPair = ([ key ]) => key;
const pickValueFromPair = ([ , value ]) => value; const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component { export default class SortableBarGraph extends React.Component {
static propTypes = { static propTypes = {
stats: PropTypes.object.isRequired, stats: PropTypes.object.isRequired,
highlightedStats: PropTypes.object,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired, sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func, extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool, withPagination: PropTypes.bool,
onClick: PropTypes.func,
}; };
state = { state = {
@@ -27,7 +31,7 @@ export default class SortableBarGraph extends React.Component {
itemsPerPage: Infinity, itemsPerPage: Infinity,
}; };
determineStats(stats, sortingItems) { getSortedPairsForStats(stats, sortingItems) {
const pairs = toPairs(stats); const pairs = toPairs(stats);
const sortedPairs = !this.state.orderField ? pairs : sortBy( const sortedPairs = !this.state.orderField ? pairs : sortBy(
pipe( pipe(
@@ -36,18 +40,33 @@ export default class SortableBarGraph extends React.Component {
), ),
pairs pairs
); );
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
if (directionalPairs.length <= this.state.itemsPerPage) { return !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
return { currentPageStats: fromPairs(directionalPairs) }; }
determineStats(stats, highlightedStats, sortingItems) {
const sortedPairs = this.getSortedPairsForStats(stats, sortingItems);
const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values
const sortedHighlightedPairs = highlightedStats && toPairs(
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...highlightedStats }
);
if (sortedPairs.length <= this.state.itemsPerPage) {
return {
currentPageStats: fromPairs(sortedPairs),
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
};
} }
const pages = splitEvery(this.state.itemsPerPage, directionalPairs); const pages = splitEvery(this.state.itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(this.state.itemsPerPage, sortedHighlightedPairs);
return { return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)), currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(this.determineCurrentPagePairs(highlightedPages)),
pagination: this.renderPagination(pages.length), pagination: this.renderPagination(pages.length),
max: roundTen(max(...directionalPairs.map(pickValueFromPair))), max: roundTen(max(...sortedPairs.map(pickValueFromPair))),
}; };
} }
@@ -72,8 +91,20 @@ export default class SortableBarGraph extends React.Component {
} }
render() { render() {
const { stats, sortingItems, title, extraHeaderContent, withPagination = true } = this.props; const {
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems); stats,
highlightedStats,
sortingItems,
title,
extraHeaderContent,
withPagination = true,
...rest
} = this.props;
const { currentPageStats, currentPageHighlightedStats, pagination, max } = this.determineStats(
stats,
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
sortingItems
);
const activeCities = keys(currentPageStats); const activeCities = keys(currentPageStats);
const computeTitle = () => ( const computeTitle = () => (
<React.Fragment> <React.Fragment>
@@ -106,6 +137,16 @@ export default class SortableBarGraph extends React.Component {
</React.Fragment> </React.Fragment>
); );
return <GraphCard isBarChart title={computeTitle} stats={currentPageStats} footer={pagination} max={max} />; return (
<GraphCard
isBarChart
title={computeTitle}
stats={currentPageStats}
highlightedStats={currentPageHighlightedStats}
footer={pagination}
max={max}
{...rest}
/>
);
} }
} }

206
src/visits/VisitsTable.js Normal file
View File

@@ -0,0 +1,206 @@
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import Moment from 'react-moment';
import classNames from 'classnames';
import { min, splitEvery } from 'ramda';
import {
faCaretDown as caretDownIcon,
faCaretUp as caretUpIcon,
faCheck as checkIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SimplePaginator from '../common/SimplePaginator';
import SearchField from '../utils/SearchField';
import { determineOrderDir } from '../utils/utils';
import { prettify } from '../utils/helpers/numbers';
import './VisitsTable.scss';
const NormalizedVisitType = PropTypes.shape({
});
const propTypes = {
visits: PropTypes.arrayOf(NormalizedVisitType).isRequired,
selectedVisits: PropTypes.arrayOf(NormalizedVisitType),
setSelectedVisits: PropTypes.func.isRequired,
isSticky: PropTypes.bool,
matchMedia: PropTypes.func,
};
const PAGE_SIZE = 20;
const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) =>
`${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase());
const searchVisits = (searchTerm, visits) => visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
const sortVisits = ({ field, dir }, visits) => visits.sort((a, b) => {
const greaterThan = dir === 'ASC' ? 1 : -1;
const smallerThan = dir === 'ASC' ? -1 : 1;
return a[field] > b[field] ? greaterThan : smallerThan;
});
const calculateVisits = (allVisits, searchTerm, order) => {
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
const sortedVisits = order.dir ? sortVisits(order, filteredVisits) : filteredVisits;
const total = sortedVisits.length;
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
return { visitsGroups, total };
};
const VisitsTable = ({
visits,
selectedVisits = [],
setSelectedVisits,
isSticky = false,
matchMedia = window.matchMedia,
}) => {
const headerCellsClass = classNames('visits-table__header-cell', {
'visits-table__sticky': isSticky,
});
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
const [ searchTerm, setSearchTerm ] = useState(undefined);
const [ order, setOrder ] = useState({ field: undefined, dir: undefined });
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
const [ page, setPage ] = useState(1);
const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE;
const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
const renderOrderIcon = (field) => order.dir && order.field === field && (
<FontAwesomeIcon
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
className="visits-table__header-icon"
/>
);
useEffect(() => {
const listener = () => setIsMobileDevice(matchMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
useEffect(() => {
setPage(1);
setSelectedVisits([]);
}, [ searchTerm ]);
return (
<table className="table table-striped table-bordered table-hover table-sm visits-table">
<thead className="visits-table__header">
<tr>
<th
className={classNames('visits-table__header-cell text-center', {
'visits-table__sticky': isSticky,
})}
onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : []
)}
>
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th>
<th className={headerCellsClass} onClick={orderByColumn('date')}>
Date
{renderOrderIcon('date')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('country')}>
Country
{renderOrderIcon('country')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('city')}>
City
{renderOrderIcon('city')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
Browser
{renderOrderIcon('browser')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('os')}>
OS
{renderOrderIcon('os')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
Referrer
{renderOrderIcon('referer')}
</th>
</tr>
<tr>
<td colSpan={7} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} />
</td>
</tr>
</thead>
<tbody>
{(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && (
<tr>
<td colSpan={7} className="text-center">
No visits found with current filtering
</td>
</tr>
)}
{resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => {
const isSelected = selectedVisits.includes(visit);
return (
<tr
key={index}
style={{ cursor: 'pointer' }}
className={classNames({ 'table-primary': isSelected })}
onClick={() => setSelectedVisits(
isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ]
)}
>
<td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td>
<td>
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
</td>
<td>{visit.country}</td>
<td>{visit.city}</td>
<td>{visit.browser}</td>
<td>{visit.os}</td>
<td>{visit.referer}</td>
</tr>
);
})}
</tbody>
{resultSet.total > PAGE_SIZE && (
<tfoot>
<tr>
<td colSpan={7} className={classNames('visits-table__footer-cell', { 'visits-table__sticky': isSticky })}>
<div className="row">
<div className="col-md-6">
<SimplePaginator
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)}
currentPage={page}
setCurrentPage={setPage}
centered={isMobileDevice}
/>
</div>
<div
className={classNames('col-md-6', {
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
'text-center mt-3': isMobileDevice,
})}
>
<div>
Visits <b>{prettify(start + 1)}</b> to{' '}
<b>{prettify(min(end, resultSet.total))}</b> of{' '}
<b>{prettify(resultSet.total)}</b>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
)}
</table>
);
};
VisitsTable.propTypes = propTypes;
export default VisitsTable;

View File

@@ -0,0 +1,35 @@
@import '../utils/base';
@import '../utils/mixins/sticky-cell';
.visits-table {
margin: 1.5rem 0 0;
position: relative;
}
.visits-table__header-cell {
cursor: pointer;
margin-bottom: 55px;
@include sticky-cell();
&.visits-table__sticky {
top: $headerHeight - 2px;
}
}
.visits-table__header-icon {
float: right;
margin-top: 3px;
}
.visits-table__footer-cell.visits-table__footer-cell {
bottom: 0;
margin-top: 34px;
padding: .5rem;
@include sticky-cell();
}
.visits-table__sticky.visits-table__sticky {
position: sticky;
}

View File

@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons'; import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap'; import { Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import { useToggle } from '../../utils/helpers/hooks';
import './OpenMapModalBtn.scss'; import './OpenMapModalBtn.scss';
const propTypes = { const propTypes = {
@@ -13,26 +14,25 @@ const propTypes = {
const OpenMapModalBtn = (MapModal) => { const OpenMapModalBtn = (MapModal) => {
const OpenMapModalBtn = ({ modalTitle, locations = [], activeCities }) => { const OpenMapModalBtn = ({ modalTitle, locations = [], activeCities }) => {
const [ mapIsOpened, setMapIsOpened ] = useState(false); const [ mapIsOpened, , openMap, closeMap ] = useToggle();
const [ dropdownIsOpened, setDropdownIsOpened ] = useState(false); const [ dropdownIsOpened, toggleDropdown, openDropdown ] = useToggle();
const [ locationsToShow, setLocationsToShow ] = useState([]); const [ locationsToShow, setLocationsToShow ] = useState([]);
const buttonRef = React.createRef(); const buttonRef = React.createRef();
const filterLocations = (locations) => locations.filter(({ cityName }) => activeCities.includes(cityName)); const filterLocations = (locations) => locations.filter(({ cityName }) => activeCities.includes(cityName));
const toggleMap = () => setMapIsOpened(!mapIsOpened);
const onClick = () => { const onClick = () => {
if (!activeCities) { if (!activeCities) {
setLocationsToShow(locations); setLocationsToShow(locations);
setMapIsOpened(true); openMap();
return; return;
} }
setDropdownIsOpened(true); openDropdown();
}; };
const openMapWithLocations = (filtered) => () => { const openMapWithLocations = (filtered) => () => {
setLocationsToShow(filtered ? filterLocations(locations) : locations); setLocationsToShow(filtered ? filterLocations(locations) : locations);
setMapIsOpened(true); openMap();
}; };
return ( return (
@@ -41,13 +41,13 @@ const OpenMapModalBtn = (MapModal) => {
<FontAwesomeIcon icon={mapIcon} /> <FontAwesomeIcon icon={mapIcon} />
</button> </button>
<UncontrolledTooltip placement="left" target={() => buttonRef.current}>Show in map</UncontrolledTooltip> <UncontrolledTooltip placement="left" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
<Dropdown isOpen={dropdownIsOpened} toggle={() => setDropdownIsOpened(!dropdownIsOpened)} inNavbar> <Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown} inNavbar>
<DropdownMenu right> <DropdownMenu right>
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem> <DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem> <DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<MapModal toggle={toggleMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} /> <MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</React.Fragment> </React.Fragment>
); );
}; };

View File

@@ -28,8 +28,7 @@ export default handleActions({
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => { export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_DETAIL_START }); dispatch({ type: GET_SHORT_URL_DETAIL_START });
const { getShortUrl } = buildShlinkApiClient(getState);
const { getShortUrl } = await buildShlinkApiClient(getState);
try { try {
const shortUrl = await getShortUrl(shortCode, domain); const shortUrl = await getShortUrl(shortCode, domain);

View File

@@ -10,8 +10,24 @@ export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_V
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL'; export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export const visitType = PropTypes.shape({
referer: PropTypes.string,
date: PropTypes.string,
userAgent: PropTypes.string,
visitLocations: PropTypes.shape({
countryCode: PropTypes.string,
countryName: PropTypes.string,
regionName: PropTypes.string,
cityName: PropTypes.string,
latitude: PropTypes.number,
longitude: PropTypes.number,
timezone: PropTypes.string,
isEmpty: PropTypes.bool,
}),
});
export const shortUrlVisitsType = PropTypes.shape({ export const shortUrlVisitsType = PropTypes.shape({
visits: PropTypes.array, visits: PropTypes.arrayOf(visitType),
loading: PropTypes.bool, loading: PropTypes.bool,
error: PropTypes.bool, error: PropTypes.bool,
}); });
@@ -51,8 +67,7 @@ export default handleActions({
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => { export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START }); dispatch({ type: GET_SHORT_URL_VISITS_START });
const { getShortUrlVisits } = buildShlinkApiClient(getState);
const { getShortUrlVisits } = await buildShlinkApiClient(getState);
const itemsPerPage = 5000; const itemsPerPage = 5000;
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;

View File

@@ -1,102 +1,52 @@
import { isNil, isEmpty, memoizeWith, prop } from 'ramda'; import { isNil, map } from 'ramda';
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
import { hasValue } from '../../utils/utils';
const osFromUserAgent = (userAgent) => { const visitHasProperty = (visit, propertyName) => !isNil(visit) && hasValue(visit[propertyName]);
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case lowerUserAgent.indexOf('linux') >= 0:
return 'Linux';
case lowerUserAgent.indexOf('windows') >= 0:
return 'Windows';
case lowerUserAgent.indexOf('mac') >= 0:
return 'MacOS';
case lowerUserAgent.indexOf('mobi') >= 0:
return 'Mobile';
default:
return 'Others';
}
};
const browserFromUserAgent = (userAgent) => {
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case lowerUserAgent.indexOf('opera') >= 0 || lowerUserAgent.indexOf('opr') >= 0:
return 'Opera';
case lowerUserAgent.indexOf('firefox') >= 0:
return 'Firefox';
case lowerUserAgent.indexOf('chrome') >= 0:
return 'Chrome';
case lowerUserAgent.indexOf('safari') >= 0:
return 'Safari';
case lowerUserAgent.indexOf('msie') >= 0:
return 'Internet Explorer';
default:
return 'Others';
}
};
const extractDomain = (url) => {
const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
return domain.split(':')[0];
};
const visitLocationHasProperty = (visitLocation, propertyName) =>
!isNil(visitLocation)
&& !isNil(visitLocation[propertyName])
&& !isEmpty(visitLocation[propertyName]);
const updateOsStatsForVisit = (osStats, { userAgent }) => {
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
const updateOsStatsForVisit = (osStats, { os }) => {
osStats[os] = (osStats[os] || 0) + 1; osStats[os] = (osStats[os] || 0) + 1;
}; };
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => { const updateBrowsersStatsForVisit = (browsersStats, { browser }) => {
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
browsersStats[browser] = (browsersStats[browser] || 0) + 1; browsersStats[browser] = (browsersStats[browser] || 0) + 1;
}; };
const updateReferrersStatsForVisit = (referrersStats, { referer }) => { const updateReferrersStatsForVisit = (referrersStats, { referer: domain }) => {
const notHasDomain = isNil(referer) || isEmpty(referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(referer);
referrersStats[domain] = (referrersStats[domain] || 0) + 1; referrersStats[domain] = (referrersStats[domain] || 0) + 1;
}; };
const updateLocationsStatsForVisit = (propertyName) => (stats, { visitLocation }) => { const updateLocationsStatsForVisit = (propertyName) => (stats, visit) => {
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName); const hasLocationProperty = visitHasProperty(visit, propertyName);
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown'; const value = hasLocationProperty ? visit[propertyName] : 'Unknown';
stats[value] = (stats[value] || 0) + 1; stats[value] = (stats[value] || 0) + 1;
}; };
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('countryName'); const updateCountriesStatsForVisit = updateLocationsStatsForVisit('country');
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('cityName'); const updateCitiesStatsForVisit = updateLocationsStatsForVisit('city');
const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => { const updateCitiesForMapForVisit = (citiesForMapStats, visit) => {
if (!visitLocationHasProperty(visitLocation, 'cityName')) { if (!visitHasProperty(visit, 'city') || visit.city === 'Unknown') {
return; return;
} }
const { cityName, latitude, longitude } = visitLocation; const { city, latitude, longitude } = visit;
const currentCity = citiesForMapStats[cityName] || { const currentCity = citiesForMapStats[city] || {
cityName, cityName: city,
count: 0, count: 0,
latLong: [ parseFloat(latitude), parseFloat(longitude) ], latLong: [ parseFloat(latitude), parseFloat(longitude) ],
}; };
currentCity.count++; currentCity.count++;
citiesForMapStats[cityName] = currentCity; citiesForMapStats[city] = currentCity;
}; };
export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) => export const processStatsFromVisits = (normalizedVisits) =>
visits.reduce( normalizedVisits.reduce(
(stats, visit) => { (stats, visit) => {
// We mutate the original object because it has a big side effect when large data sets are processed // We mutate the original object because it has a big performance impact when large data sets are processed
updateOsStatsForVisit(stats.os, visit); updateOsStatsForVisit(stats.os, visit);
updateBrowsersStatsForVisit(stats.browsers, visit); updateBrowsersStatsForVisit(stats.browsers, visit);
updateReferrersStatsForVisit(stats.referrers, visit); updateReferrersStatsForVisit(stats.referrers, visit);
@@ -107,4 +57,19 @@ export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
return stats; return stats;
}, },
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
)); );
export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => {
const { browser, os } = parseUserAgent(userAgent);
return {
date,
browser,
os,
referer: extractDomain(referer),
country: (visitLocation && visitLocation.countryName) || 'Unknown',
city: (visitLocation && visitLocation.cityName) || 'Unknown',
latitude: visitLocation && visitLocation.latitude,
longitude: visitLocation && visitLocation.longitude,
};
});

View File

@@ -9,7 +9,7 @@ describe('<App />', () => {
const MainHeader = () => ''; const MainHeader = () => '';
beforeEach(() => { beforeEach(() => {
const App = appFactory(MainHeader, identity, identity, identity); const App = appFactory(MainHeader, identity, identity, identity, identity);
wrapper = shallow(<App />); wrapper = shallow(<App />);
}); });
@@ -20,13 +20,14 @@ describe('<App />', () => {
it('renders app main routes', () => { it('renders app main routes', () => {
const routes = wrapper.find(Route); const routes = wrapper.find(Route);
const expectedPaths = [ const expectedPaths = [
'/server/create',
'/', '/',
'/server/create',
'/server/:serverId/edit',
'/server/:serverId', '/server/:serverId',
]; ];
expect.assertions(expectedPaths.length + 1); expect.assertions(expectedPaths.length + 1);
expect(routes).toHaveLength(4); expect(routes).toHaveLength(expectedPaths.length + 1);
expectedPaths.forEach((path, index) => { expectedPaths.forEach((path, index) => {
expect(routes.at(index).prop('path')).toEqual(path); expect(routes.at(index).prop('path')).toEqual(path);
}); });

View File

@@ -1,6 +1,5 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom';
import asideMenuCreator from '../../src/common/AsideMenu'; import asideMenuCreator from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => { describe('<AsideMenu />', () => {
@@ -15,9 +14,9 @@ describe('<AsideMenu />', () => {
afterEach(() => wrapped.unmount()); afterEach(() => wrapped.unmount());
it('contains links to different sections', () => { it('contains links to different sections', () => {
const links = wrapped.find(NavLink); const links = wrapped.find('[to]');
expect(links).toHaveLength(3); expect(links).toHaveLength(4);
links.forEach((link) => expect(link.prop('to')).toContain('abc123')); links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
}); });

View File

@@ -1,12 +1,11 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { values } from 'ramda';
import React from 'react'; import React from 'react';
import Home from '../../src/common/Home'; import Home from '../../src/common/Home';
describe('<Home />', () => { describe('<Home />', () => {
let wrapped; let wrapped;
const defaultProps = { const defaultProps = {
resetSelectedServer: () => '', resetSelectedServer: jest.fn(),
servers: { loading: false, list: {} }, servers: { loading: false, list: {} },
}; };
const createComponent = (props) => { const createComponent = (props) => {
@@ -17,26 +16,12 @@ describe('<Home />', () => {
return wrapped; return wrapped;
}; };
afterEach(() => { afterEach(() => wrapped && wrapped.unmount());
if (wrapped !== undefined) {
wrapped.unmount();
wrapped = undefined;
}
});
it('resets selected server when mounted', () => {
const resetSelectedServer = jest.fn();
expect(resetSelectedServer).not.toHaveBeenCalled();
createComponent({ resetSelectedServer });
expect(resetSelectedServer).toHaveBeenCalled();
});
it('shows link to create server when no servers exist', () => { it('shows link to create server when no servers exist', () => {
const wrapped = createComponent(); const wrapped = createComponent();
expect(wrapped.find('Link')).toHaveLength(1); expect(wrapped.find('Link')).toHaveLength(1);
expect(wrapped.find('ListGroup')).toHaveLength(0);
}); });
it('shows message when loading servers', () => { it('shows message when loading servers', () => {
@@ -45,21 +30,17 @@ describe('<Home />', () => {
expect(span).toHaveLength(1); expect(span).toHaveLength(1);
expect(span.text()).toContain('Trying to load servers...'); 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', () => { it('Asks to select a server when not loadign and servers exist', () => {
const servers = { const list = [
loading: false, { name: 'foo', id: '1' },
list: { { name: 'bar', id: '2' },
1: { name: 'foo', id: '123' }, ];
2: { name: 'bar', id: '456' }, const wrapped = createComponent({ servers: { list } });
}, const span = wrapped.find('span');
};
const wrapped = createComponent({ servers });
expect(wrapped.find('Link')).toHaveLength(0); expect(span).toHaveLength(1);
expect(wrapped.find('ListGroup')).toHaveLength(1); expect(span.text()).toContain('Please, select a server.');
expect(wrapped.find('ListGroupItem')).toHaveLength(values(servers).length);
}); });
}); });

View File

@@ -38,7 +38,7 @@ describe('<NotFound />', () => {
}); });
it('shows a link with provided props', () => { it('shows a link with provided props', () => {
const { wrapper } = createWrapper({ to: '/foo/bar', btnText: 'Hello' }); const { wrapper } = createWrapper({ to: '/foo/bar', children: 'Hello' });
const link = wrapper.find(Link); const link = wrapper.find(Link);
expect(link.prop('to')).toEqual('/foo/bar'); expect(link.prop('to')).toEqual('/foo/bar');

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { shallow } from 'enzyme';
import ShlinkVersions from '../../src/common/ShlinkVersions';
describe('<ShlinkVersions />', () => {
let wrapper;
const createWrapper = (props) => {
wrapper = shallow(<ShlinkVersions {...props} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it.each([
[ '1.2.3', 'foo', 'Client: v1.2.3 - Server: foo' ],
[ 'foo', '1.2.3', 'Client: latest - Server: 1.2.3' ],
[ 'latest', 'latest', 'Client: latest - Server: latest' ],
[ '5.5.0', '0.2.8', 'Client: v5.5.0 - Server: 0.2.8' ],
[ 'not-semver', 'something', 'Client: latest - Server: something' ],
])('displays expected versions', (clientVersion, printableVersion, expected) => {
const wrapper = createWrapper({ clientVersion, selectedServer: { printableVersion } });
expect(wrapper.text()).toEqual(expected);
});
});

View File

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

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