mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-14 11:33:51 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019ce2e8ed | ||
|
|
e93082a64d | ||
|
|
8a9a4f40a7 | ||
|
|
047d99be6d | ||
|
|
16cf30f26f | ||
|
|
9268114fe1 | ||
|
|
fefbb73568 | ||
|
|
12cddac27a | ||
|
|
77a2f32cfd | ||
|
|
9d513e9ea0 | ||
|
|
9df2de5b30 | ||
|
|
fd2367b005 | ||
|
|
a2b08277dc | ||
|
|
8bd3a15a1d | ||
|
|
cd11dd9848 | ||
|
|
eec79043cc | ||
|
|
4b1f5e9f4c | ||
|
|
cf1239cf6e | ||
|
|
566322a8c5 | ||
|
|
fa3e1eba93 | ||
|
|
471322f4db | ||
|
|
79a0a5e4ea | ||
|
|
4f54e3315f | ||
|
|
7bd4b39b5a | ||
|
|
12ddeebedf | ||
|
|
d6e53918a2 | ||
|
|
bab1e57ab1 | ||
|
|
bec755b121 | ||
|
|
5616d045ab | ||
|
|
5e6ad14a85 | ||
|
|
79a518b02d | ||
|
|
e996a08c02 | ||
|
|
cc206c2843 | ||
|
|
591c3b76f9 | ||
|
|
07b1d5be2e | ||
|
|
f94b5b7c68 | ||
|
|
824a2facac | ||
|
|
4445c79540 | ||
|
|
85cb849ba5 | ||
|
|
53132fa900 | ||
|
|
c774a00610 | ||
|
|
1697ef9306 | ||
|
|
79a16a2c2c | ||
|
|
30192cb349 | ||
|
|
8d0c0bcc99 | ||
|
|
70ebb0362a | ||
|
|
cccf57a35a | ||
|
|
756e0c637e | ||
|
|
44541d5e97 | ||
|
|
655045c975 | ||
|
|
6784c30fa0 | ||
|
|
a65aadd4b2 | ||
|
|
3c12bc1434 | ||
|
|
822afa6db7 | ||
|
|
0c1c471714 | ||
|
|
b1b215e84a | ||
|
|
7a63f737ac | ||
|
|
4adf618026 | ||
|
|
f1c464fd3e | ||
|
|
99833b51a9 | ||
|
|
05936c52b3 | ||
|
|
368de2b4c7 | ||
|
|
6634fc41c5 | ||
|
|
4ad8e909d4 | ||
|
|
56ad6d9e1b | ||
|
|
169c69df2c | ||
|
|
0e8631ae9d | ||
|
|
812e391e34 | ||
|
|
4c1a044fd3 | ||
|
|
bb17dbe680 | ||
|
|
160de66b44 | ||
|
|
02b38cf84a | ||
|
|
2101dadfd7 | ||
|
|
782a5c1d35 | ||
|
|
de9f20b7a6 | ||
|
|
644caf7dfb | ||
|
|
f26deb51eb | ||
|
|
606397b542 | ||
|
|
bc8eaaaee4 | ||
|
|
7d665f3933 | ||
|
|
fc1af04243 | ||
|
|
f2d03203ae | ||
|
|
2d6dda3576 | ||
|
|
9b3bfe56bb | ||
|
|
5d5a2be498 | ||
|
|
64c1b56973 | ||
|
|
d37e7ca7ce | ||
|
|
eb0f219403 | ||
|
|
0c1656285b | ||
|
|
bbce53ade6 | ||
|
|
3e63734e2b | ||
|
|
f0b0fdf114 | ||
|
|
f84e3c5b60 | ||
|
|
28bd39f974 | ||
|
|
8b17ff88ed | ||
|
|
0d97c084c2 | ||
|
|
b7ca32ff8f | ||
|
|
b454810357 | ||
|
|
fd57d70a0b | ||
|
|
b0bce7498a | ||
|
|
1519f89318 | ||
|
|
0b089e24de |
@@ -2,6 +2,7 @@
|
|||||||
"extends": [
|
"extends": [
|
||||||
"adidas-env/browser",
|
"adidas-env/browser",
|
||||||
"adidas-env/module",
|
"adidas-env/module",
|
||||||
|
"adidas-env/node",
|
||||||
"adidas-es6",
|
"adidas-es6",
|
||||||
"adidas-babel",
|
"adidas-babel",
|
||||||
"adidas-react"
|
"adidas-react"
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
|
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
|
||||||
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
|
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
|
||||||
"react/no-array-index-key": "off",
|
"react/no-array-index-key": "off",
|
||||||
"react/no-did-update-set-state": "off"
|
"react/no-did-update-set-state": "off",
|
||||||
|
"react/display-name": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
.travis.yml
19
.travis.yml
@@ -1,5 +1,7 @@
|
|||||||
language: node_js
|
language: node_js
|
||||||
|
|
||||||
|
sudo: false
|
||||||
|
|
||||||
node_js:
|
node_js:
|
||||||
- "stable"
|
- "stable"
|
||||||
|
|
||||||
@@ -14,9 +16,20 @@ install:
|
|||||||
script:
|
script:
|
||||||
- yarn lint
|
- yarn lint
|
||||||
- yarn test:ci
|
- yarn test:ci
|
||||||
- yarn build # Make sure the app can be built without errors
|
- if [[ -z $TRAVIS_TAG ]]; then yarn build ; fi
|
||||||
|
|
||||||
after_script:
|
after_success:
|
||||||
- yarn ocular coverage/clover.xml
|
- yarn ocular coverage/clover.xml
|
||||||
|
|
||||||
sudo: false
|
# Before deploying, build dist file for current travis tag
|
||||||
|
before_deploy:
|
||||||
|
- yarn build ${TRAVIS_TAG#?}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
provider: releases
|
||||||
|
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
|
||||||
|
|||||||
107
CHANGELOG.md
107
CHANGELOG.md
@@ -1,5 +1,110 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 1.2.1 - 2018-12-21
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#80](https://github.com/shlinkio/shlink-web-client/issues/80) Deeply refactored app to do true dependency injection with an IoC container.
|
||||||
|
* [#79](https://github.com/shlinkio/shlink-web-client/issues/79) Updated to nginx 1.15.7 as the base docker image.
|
||||||
|
* [#75](https://github.com/shlinkio/shlink-web-client/issues/75) Prevented duplicated `yarn build` in travis when a tag exists.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#77](https://github.com/shlinkio/shlink-web-client/issues/77) Sortable graphs ordering is now case insensitive.
|
||||||
|
|
||||||
|
|
||||||
|
## 1.2.0 - 2018-11-01
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#65](https://github.com/shlinkio/shlink-web-client/issues/65) Added sorting to both countries and referrers stats graphs.
|
||||||
|
* [#14](https://github.com/shlinkio/shlink-web-client/issues/14) Documented how to build the project so that it can be served from a subpath.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#50](https://github.com/shlinkio/shlink-web-client/issues/50) Improved tests and increased code coverage.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#66](https://github.com/shlinkio/shlink-web-client/issues/66) Fixed tooltips in graphs with too small bars not being displayed.
|
||||||
|
|
||||||
|
|
||||||
|
## 1.1.1 - 2018-10-20
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#57](https://github.com/shlinkio/shlink-web-client/issues/57) Automated release generation in travis build.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#63](https://github.com/shlinkio/shlink-web-client/issues/63) Improved how bar charts are rendered in stats page, making them try to calculate a bigger height for big data sets.
|
||||||
|
* [#56](https://github.com/shlinkio/shlink-web-client/issues/56) Ensured `ColorGenerator` matches keys in a case insensitive way.
|
||||||
|
* [#53](https://github.com/shlinkio/shlink-web-client/issues/53) Fixed missing margin between date fields in visits page for mobile devices.
|
||||||
|
|
||||||
|
|
||||||
|
## 1.1.0 - 2018-09-16
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#47](https://github.com/shlinkio/shlink-web-client/issues/47) Added support to delete short URLs (requires [shlink v1.12.0](https://github.com/shlinkio/shlink/releases/tag/v1.12.0) or greater).
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#35](https://github.com/shlinkio/shlink-web-client/issues/35) Visits component split into two, which makes the header not to be refreshed when filtering by date, and also the visits global counter now reflects the actual number of visits which fulfill current filter.
|
||||||
|
* [#36](https://github.com/shlinkio/shlink-web-client/issues/36) Tags selector now autocompletes existing tag names, to prevent typos and ease reusing existing tags.
|
||||||
|
* [#39](https://github.com/shlinkio/shlink-web-client/issues/39) Defined `propTypes` as static properties in class components.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#49](https://github.com/shlinkio/shlink-web-client/issues/49) Ensured filtering parameters are reseted when list component is unmounted so that params are not mixed when coming back.
|
||||||
|
* [#45](https://github.com/shlinkio/shlink-web-client/issues/45) Ensured graphs x-axis start at `0` and don't use decimals.
|
||||||
|
* [#51](https://github.com/shlinkio/shlink-web-client/issues/51) When editing short URL tags, the value returned form server is used when refreshing the list, which is normalized.
|
||||||
|
|
||||||
|
|
||||||
## 1.0.1 - 2018-09-02
|
## 1.0.1 - 2018-09-02
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -33,7 +138,7 @@
|
|||||||
* Export all servers in a CSV file.
|
* Export all servers in a CSV file.
|
||||||
* Import the CSV in a different device.
|
* Import the CSV in a different device.
|
||||||
|
|
||||||
* [#4](https://github.com/shlinkio/shlink-web-client/issues/4) Added tags management.
|
* [#3](https://github.com/shlinkio/shlink-web-client/issues/3) Added tags management.
|
||||||
|
|
||||||
* List existing tags, and filter the list.
|
* List existing tags, and filter the list.
|
||||||
* Change their name and color.
|
* Change their name and color.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM nginx:1.15.2-alpine
|
FROM nginx:1.15.7-alpine
|
||||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||||
|
|
||||||
# Install node and yarn
|
# Install node and yarn
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -1,10 +1,11 @@
|
|||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||||
[](https://scrutinizer-ci.com/gshlinkio/shlink-web-client/?branch=master)
|
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
||||||
|
[](https://acel.me/donate)
|
||||||
|
|
||||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||||
|
|
||||||
@@ -20,10 +21,30 @@ There are three ways in which you can use this application.
|
|||||||
|
|
||||||
Get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
Get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
||||||
|
|
||||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice (just take into account that all the files are served using absolute paths, so you have to serve it from the root of your domain, not from a subpath).
|
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
|
||||||
|
|
||||||
|
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these simple steps](#serve-shlink-in-subpath).
|
||||||
|
|
||||||
* Use the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
* Use the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
|
|
||||||
If you want to deploy shlink-web-client in a container-based cluster (docker swarm, kubernetes, etc), just pick the image and do it.
|
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the image and do it.
|
||||||
|
|
||||||
It's a lightweight [nginx:alpine image](https://hub.docker.com/r/library/nginx/) serving the assets on port 80.
|
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the assets on port 80.
|
||||||
|
|
||||||
|
## Serve project in subpath
|
||||||
|
|
||||||
|
Official distributable files have been build so that they are served from the root of a domain.
|
||||||
|
|
||||||
|
If you need to host shlink-web-client yourself and serve it from a subpath, follow these steps:
|
||||||
|
|
||||||
|
* Download [node](https://nodejs.org/en/download/package-manager/) 10.4 or later (if you don't have it yet).
|
||||||
|
* Download [yarn](https://yarnpkg.com/en/docs/install) package manager.
|
||||||
|
* Download shlink-web-client source files for the version you want to build.
|
||||||
|
* For example, if you want to build `v1.0.1`, use this link https://github.com/shlinkio/shlink-web-client/archive/v1.0.1.zip
|
||||||
|
* Replace the `v1.0.1` part in the link with the one of the version you want to build.
|
||||||
|
* Decompress the file and `cd` into the resulting folder.
|
||||||
|
* Install project dependencies by running `yarn install`.
|
||||||
|
* Open the `package.json` file in the root of the project, locate the `homepage` property and replace the value (which should be an empty string) by the path from which you want to serve shlink-web-client.
|
||||||
|
* For example: `"homepage": "/my-projects/shlink-web-client",`.
|
||||||
|
* Build the distributable contents by running `yarn build`.
|
||||||
|
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
|
||||||
|
|||||||
6
indocker
6
indocker
@@ -1,2 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Run docker container if it's not up yet
|
||||||
|
if ! [[ $(docker ps | grep shlink_web_client_node) ]]; then
|
||||||
|
docker-compose up -d
|
||||||
|
fi
|
||||||
|
|
||||||
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"
|
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"
|
||||||
|
|||||||
36
jest.config.js
Normal file
36
jest.config.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
module.exports = {
|
||||||
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.js',
|
||||||
|
'!src/registerServiceWorker.js',
|
||||||
|
'!src/index.js',
|
||||||
|
'!src/reducers/index.js',
|
||||||
|
'!src/**/provideServices.js',
|
||||||
|
'!src/container/*.js',
|
||||||
|
],
|
||||||
|
setupFiles: [
|
||||||
|
'<rootDir>/config/polyfills.js',
|
||||||
|
'<rootDir>/config/setupEnzyme.js',
|
||||||
|
],
|
||||||
|
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,mjs}' ],
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testURL: 'http://localhost',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||||
|
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
||||||
|
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$' ],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^react-native$': 'react-native-web',
|
||||||
|
},
|
||||||
|
moduleFileExtensions: [
|
||||||
|
'web.js',
|
||||||
|
'js',
|
||||||
|
'json',
|
||||||
|
'web.jsx',
|
||||||
|
'jsx',
|
||||||
|
'node',
|
||||||
|
'mjs',
|
||||||
|
],
|
||||||
|
};
|
||||||
43
package.json
43
package.json
@@ -3,13 +3,15 @@
|
|||||||
"description": "A React-based progressive web application for shlink",
|
"description": "A React-based progressive web application for shlink",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
"homepage": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "yarn lint:js && yarn lint:css",
|
"lint": "yarn lint:js && yarn lint:css",
|
||||||
"lint:js": "eslint src test scripts config",
|
"lint:js": "eslint src test scripts config",
|
||||||
"lint:js:fix": "yarn lint:js --fix",
|
"lint:js:fix": "yarn lint:js --fix",
|
||||||
"lint:css": "stylelint src/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||||
"lint:css:fix": "yarn lint:css --fix",
|
"lint:css:fix": "yarn lint:css --fix",
|
||||||
"start": "node scripts/start.js",
|
"start": "node scripts/start.js",
|
||||||
|
"serve:build": "yarn serve ./build",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js",
|
||||||
"test": "node scripts/test.js --env=jsdom --colors",
|
"test": "node scripts/test.js --env=jsdom --colors",
|
||||||
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
"@fortawesome/react-fontawesome": "0.0.19",
|
"@fortawesome/react-fontawesome": "0.0.19",
|
||||||
"axios": "^0.18.0",
|
"axios": "^0.18.0",
|
||||||
"bootstrap": "^4.1.1",
|
"bootstrap": "^4.1.1",
|
||||||
|
"bottlejs": "^1.7.1",
|
||||||
"chart.js": "^2.7.2",
|
"chart.js": "^2.7.2",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
@@ -30,7 +33,8 @@
|
|||||||
"prop-types": "^15.6.2",
|
"prop-types": "^15.6.2",
|
||||||
"qs": "^6.5.2",
|
"qs": "^6.5.2",
|
||||||
"ramda": "^0.25.0",
|
"ramda": "^0.25.0",
|
||||||
"react": "^16.3.2",
|
"react": "^16.6",
|
||||||
|
"react-autosuggest": "^9.4.0",
|
||||||
"react-chartjs-2": "^2.7.4",
|
"react-chartjs-2": "^2.7.4",
|
||||||
"react-color": "^2.14.1",
|
"react-color": "^2.14.1",
|
||||||
"react-copy-to-clipboard": "^5.0.1",
|
"react-copy-to-clipboard": "^5.0.1",
|
||||||
@@ -101,41 +105,6 @@
|
|||||||
"webpack-manifest-plugin": "1.3.2",
|
"webpack-manifest-plugin": "1.3.2",
|
||||||
"whatwg-fetch": "2.0.3"
|
"whatwg-fetch": "2.0.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
|
||||||
"coverageDirectory": "<rootDir>/coverage",
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"src/**/*.{js,jsx,mjs}"
|
|
||||||
],
|
|
||||||
"setupFiles": [
|
|
||||||
"<rootDir>/config/polyfills.js",
|
|
||||||
"<rootDir>/config/setupEnzyme.js"
|
|
||||||
],
|
|
||||||
"testMatch": [
|
|
||||||
"<rootDir>/test/**/*.test.{js,jsx,mjs}"
|
|
||||||
],
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testURL": "http://localhost",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(js|jsx|mjs)$": "<rootDir>/node_modules/babel-jest",
|
|
||||||
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
|
|
||||||
"^(?!.*\\.(js|jsx|mjs|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
|
|
||||||
},
|
|
||||||
"transformIgnorePatterns": [
|
|
||||||
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"
|
|
||||||
],
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"^react-native$": "react-native-web"
|
|
||||||
},
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"web.js",
|
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"web.jsx",
|
|
||||||
"jsx",
|
|
||||||
"node",
|
|
||||||
"mjs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"babel": {
|
"babel": {
|
||||||
"presets": [
|
"presets": [
|
||||||
"react-app"
|
"react-app"
|
||||||
|
|||||||
@@ -4,17 +4,19 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<meta name="theme-color" content="#4696e5">
|
<meta name="theme-color" content="#4696e5">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
manifest.json provides metadata used when your web app is added to the
|
manifest.json provides metadata used when your web app is added to the
|
||||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||||
-->
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||||
|
|
||||||
|
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/shlink-128.png">
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/shlink-128.png">
|
||||||
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/shlink-64.png">
|
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/shlink-64.png">
|
||||||
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/shlink-32.png">
|
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/shlink-32.png">
|
||||||
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/shlink-24.png">
|
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/shlink-24.png">
|
||||||
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/shlink-16.png">
|
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/shlink-16.png">
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
|
||||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
|
||||||
<!--
|
<!--
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
|||||||
30
src/App.js
30
src/App.js
@@ -1,23 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
import Home from './common/Home';
|
|
||||||
import MainHeader from './common/MainHeader';
|
|
||||||
import MenuLayout from './common/MenuLayout';
|
|
||||||
import CreateServer from './servers/CreateServer';
|
|
||||||
|
|
||||||
export default function App() {
|
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
|
||||||
return (
|
<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="/server/create" component={CreateServer} />
|
||||||
<Route exact path="/" component={Home} />
|
<Route exact path="/" component={Home} />
|
||||||
<Route path="/server/:serverId" component={MenuLayout} />
|
<Route path="/server/:serverId" component={MenuLayout} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ 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 DeleteServerButton from '../servers/DeleteServerButton';
|
|
||||||
import './AsideMenu.scss';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
className: '',
|
className: '',
|
||||||
@@ -20,51 +19,57 @@ const propTypes = {
|
|||||||
showOnMobile: PropTypes.bool,
|
showOnMobile: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AsideMenu({ selectedServer, className, showOnMobile }) {
|
const AsideMenu = (DeleteServerButton) => {
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
||||||
const asideClass = classnames('aside-menu', className, {
|
const serverId = selectedServer ? selectedServer.id : '';
|
||||||
'aside-menu--hidden': !showOnMobile,
|
const asideClass = classnames('aside-menu', className, {
|
||||||
});
|
'aside-menu--hidden': !showOnMobile,
|
||||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
});
|
||||||
|
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
||||||
|
|
||||||
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
|
<NavLink
|
||||||
className="aside-menu__item"
|
className="aside-menu__item"
|
||||||
activeClassName="aside-menu__item--selected"
|
activeClassName="aside-menu__item--selected"
|
||||||
to={`/server/${serverId}/list-short-urls/1`}
|
to={`/server/${serverId}/list-short-urls/1`}
|
||||||
isActive={shortUrlsIsActive}
|
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>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
className="aside-menu__item"
|
className="aside-menu__item"
|
||||||
activeClassName="aside-menu__item--selected"
|
activeClassName="aside-menu__item--selected"
|
||||||
to={`/server/${serverId}/create-short-url`}
|
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>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
className="aside-menu__item"
|
className="aside-menu__item"
|
||||||
activeClassName="aside-menu__item--selected"
|
activeClassName="aside-menu__item--selected"
|
||||||
to={`/server/${serverId}/manage-tags`}
|
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>
|
</NavLink>
|
||||||
|
|
||||||
<DeleteServerButton
|
<DeleteServerButton
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
server={selectedServer}
|
server={selectedServer}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
AsideMenu.defaultProps = defaultProps;
|
AsideMenu.defaultProps = defaultProps;
|
||||||
AsideMenu.propTypes = propTypes;
|
AsideMenu.propTypes = propTypes;
|
||||||
|
|
||||||
|
return AsideMenu;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AsideMenu;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
|
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
|
||||||
import React from 'react';
|
|
||||||
import DatePicker from 'react-datepicker';
|
|
||||||
import './DateInput.scss';
|
|
||||||
import { isNil } from 'ramda';
|
|
||||||
|
|
||||||
export default class DateInput extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.inputRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { isClearable, selected } = this.props;
|
|
||||||
const showCalendarIcon = !isClearable || isNil(selected);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="date-input-container">
|
|
||||||
<DatePicker
|
|
||||||
{...this.props}
|
|
||||||
className={`date-input-container__input form-control ${this.props.className || ''}`}
|
|
||||||
dateFormat="YYYY-MM-DD"
|
|
||||||
readOnly
|
|
||||||
ref={this.inputRef}
|
|
||||||
/>
|
|
||||||
{showCalendarIcon && (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={calendarIcon}
|
|
||||||
className="date-input-container__icon"
|
|
||||||
onClick={() => this.inputRef.current.input.focus()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
|
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import { isEmpty, pick, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { resetSelectedServer } from '../servers/reducers/selectedServer';
|
|
||||||
import './Home.scss';
|
import './Home.scss';
|
||||||
|
|
||||||
const propTypes = {
|
export default class Home extends React.Component {
|
||||||
resetSelectedServer: PropTypes.func,
|
static propTypes = {
|
||||||
servers: PropTypes.object,
|
resetSelectedServer: PropTypes.func,
|
||||||
};
|
servers: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
export class HomeComponent extends React.Component {
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.resetSelectedServer();
|
this.props.resetSelectedServer();
|
||||||
}
|
}
|
||||||
@@ -50,9 +48,3 @@ export class HomeComponent extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HomeComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent);
|
|
||||||
|
|
||||||
export default Home;
|
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
|
|||||||
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
|
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, withRouter } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ServersDropdown from '../servers/ServersDropdown';
|
|
||||||
import './MainHeader.scss';
|
|
||||||
import shlinkLogo from './shlink-logo-white.png';
|
import shlinkLogo from './shlink-logo-white.png';
|
||||||
|
import './MainHeader.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
|
||||||
location: PropTypes.object,
|
static propTypes = {
|
||||||
};
|
location: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
export class MainHeaderComponent extends React.Component {
|
|
||||||
state = { isOpen: false };
|
state = { isOpen: false };
|
||||||
handleToggle = () => {
|
handleToggle = () => {
|
||||||
this.setState(({ isOpen }) => ({
|
this.setState(({ isOpen }) => ({
|
||||||
@@ -62,10 +61,6 @@ export class MainHeaderComponent extends React.Component {
|
|||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
MainHeaderComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const MainHeader = withRouter(MainHeaderComponent);
|
|
||||||
|
|
||||||
export default MainHeader;
|
export default MainHeader;
|
||||||
|
|||||||
@@ -1,115 +1,100 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route, Switch, withRouter } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { compose } from 'redux';
|
|
||||||
import { pick } from 'ramda';
|
|
||||||
import Swipeable from 'react-swipeable';
|
import Swipeable from 'react-swipeable';
|
||||||
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
|
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
|
||||||
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 ShortUrlsVisits from '../short-urls/ShortUrlVisits';
|
|
||||||
import { selectServer } from '../servers/reducers/selectedServer';
|
|
||||||
import CreateShortUrl from '../short-urls/CreateShortUrl';
|
|
||||||
import ShortUrls from '../short-urls/ShortUrls';
|
|
||||||
import './MenuLayout.scss';
|
|
||||||
import TagsList from '../tags/TagsList';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
import AsideMenu from './AsideMenu';
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
|
||||||
match: PropTypes.object,
|
class MenuLayout extends React.Component {
|
||||||
selectServer: PropTypes.func,
|
static propTypes = {
|
||||||
location: PropTypes.object,
|
match: PropTypes.object,
|
||||||
selectedServer: serverType,
|
selectServer: PropTypes.func,
|
||||||
};
|
location: PropTypes.object,
|
||||||
|
selectedServer: serverType,
|
||||||
|
};
|
||||||
|
|
||||||
export class MenuLayoutComponent extends React.Component {
|
state = { showSideBar: false };
|
||||||
state = { showSideBar: false };
|
|
||||||
|
|
||||||
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
|
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
|
||||||
/* eslint react/no-deprecated: "off" */
|
/* eslint react/no-deprecated: "off" */
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
const { match, selectServer } = this.props;
|
const { match, selectServer } = this.props;
|
||||||
const { params: { serverId } } = match;
|
const { params: { serverId } } = match;
|
||||||
|
|
||||||
selectServer(serverId);
|
selectServer(serverId);
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { location } = this.props;
|
|
||||||
|
|
||||||
// Hide sidebar when location changes
|
|
||||||
if (location !== prevProps.location) {
|
|
||||||
this.setState({ showSideBar: false });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
componentDidUpdate(prevProps) {
|
||||||
const { selectedServer } = this.props;
|
const { location } = this.props;
|
||||||
const burgerClasses = classnames('menu-layout__burger-icon', {
|
|
||||||
'menu-layout__burger-icon--active': this.state.showSideBar,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
// Hide sidebar when location changes
|
||||||
<React.Fragment>
|
if (location !== prevProps.location) {
|
||||||
<FontAwesomeIcon
|
this.setState({ showSideBar: false });
|
||||||
icon={burgerIcon}
|
}
|
||||||
className={burgerClasses}
|
}
|
||||||
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Swipeable
|
render() {
|
||||||
delta={40}
|
const { selectedServer } = this.props;
|
||||||
className="menu-layout__swipeable"
|
const burgerClasses = classnames('menu-layout__burger-icon', {
|
||||||
onSwipedLeft={() => this.setState({ showSideBar: false })}
|
'menu-layout__burger-icon--active': this.state.showSideBar,
|
||||||
onSwipedRight={() => this.setState({ showSideBar: true })}
|
});
|
||||||
>
|
|
||||||
<div className="row menu-layout__swipeable-inner">
|
return (
|
||||||
<AsideMenu
|
<React.Fragment>
|
||||||
className="col-lg-2 col-md-3"
|
<FontAwesomeIcon
|
||||||
selectedServer={selectedServer}
|
icon={burgerIcon}
|
||||||
showOnMobile={this.state.showSideBar}
|
className={burgerClasses}
|
||||||
/>
|
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
|
||||||
<div
|
/>
|
||||||
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
|
|
||||||
onClick={() => this.setState({ showSideBar: false })}
|
<Swipeable
|
||||||
>
|
delta={40}
|
||||||
<Switch>
|
className="menu-layout__swipeable"
|
||||||
<Route
|
onSwipedLeft={() => this.setState({ showSideBar: false })}
|
||||||
exact
|
onSwipedRight={() => this.setState({ showSideBar: true })}
|
||||||
path="/server/:serverId/list-short-urls/:page"
|
>
|
||||||
component={ShortUrls}
|
<div className="row menu-layout__swipeable-inner">
|
||||||
/>
|
<AsideMenu
|
||||||
<Route
|
className="col-lg-2 col-md-3"
|
||||||
exact
|
selectedServer={selectedServer}
|
||||||
path="/server/:serverId/create-short-url"
|
showOnMobile={this.state.showSideBar}
|
||||||
component={CreateShortUrl}
|
/>
|
||||||
/>
|
<div
|
||||||
<Route
|
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
|
||||||
exact
|
onClick={() => this.setState({ showSideBar: false })}
|
||||||
path="/server/:serverId/short-code/:shortCode/visits"
|
>
|
||||||
component={ShortUrlsVisits}
|
<Switch>
|
||||||
/>
|
<Route
|
||||||
<Route
|
exact
|
||||||
exact
|
path="/server/:serverId/list-short-urls/:page"
|
||||||
path="/server/:serverId/manage-tags"
|
component={ShortUrls}
|
||||||
component={TagsList}
|
/>
|
||||||
/>
|
<Route
|
||||||
</Switch>
|
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}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Swipeable>
|
||||||
</Swipeable>
|
</React.Fragment>
|
||||||
</React.Fragment>
|
);
|
||||||
);
|
}
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
MenuLayoutComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const MenuLayout = compose(
|
|
||||||
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
|
|
||||||
withRouter
|
|
||||||
)(MenuLayoutComponent);
|
|
||||||
|
|
||||||
export default MenuLayout;
|
export default MenuLayout;
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
const propTypes = {
|
const ScrollToTop = (window) => class ScrollToTop extends React.Component {
|
||||||
location: PropTypes.object,
|
static propTypes = {
|
||||||
window: PropTypes.shape({
|
location: PropTypes.object,
|
||||||
scrollTo: PropTypes.func,
|
children: PropTypes.node,
|
||||||
}),
|
};
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
const defaultProps = {
|
|
||||||
window,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ScrollToTopComponent extends React.Component {
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { location, window } = this.props;
|
const { location } = this.props;
|
||||||
|
|
||||||
if (location !== prevProps.location) {
|
if (location !== prevProps.location) {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
@@ -25,11 +18,6 @@ export class ScrollToTopComponent extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ScrollToTopComponent.defaultProps = defaultProps;
|
|
||||||
ScrollToTopComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const ScrollToTop = withRouter(ScrollToTopComponent);
|
|
||||||
|
|
||||||
export default ScrollToTop;
|
export default ScrollToTop;
|
||||||
|
|||||||
@@ -5,15 +5,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 2.6rem;
|
min-height: 2.6rem;
|
||||||
padding: 6px 0 0 6px;
|
padding: 6px 0 0 6px;
|
||||||
|
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tagsinput--focused {
|
.react-tagsinput--focused {
|
||||||
border-color: #80bdff;
|
border-color: #80bdff;
|
||||||
-webkit-box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
|
|
||||||
box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
|
box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
|
||||||
-webkit-transition: border-color .15s ease-in-out, -webkit-box-shadow .15s ease-in-out;
|
|
||||||
-o-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
|
||||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out, -webkit-box-shadow .15s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tagsinput-tag {
|
.react-tagsinput-tag {
|
||||||
@@ -44,6 +41,6 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 3px 5px;
|
padding: 3px 5px;
|
||||||
width: 155px;
|
width: 100%;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/common/services/provideServices.js
Normal file
34
src/common/services/provideServices.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import ScrollToTop from '../ScrollToTop';
|
||||||
|
import MainHeader from '../MainHeader';
|
||||||
|
import Home from '../Home';
|
||||||
|
import MenuLayout from '../MenuLayout';
|
||||||
|
import AsideMenu from '../AsideMenu';
|
||||||
|
|
||||||
|
const provideServices = (bottle, connect, withRouter) => {
|
||||||
|
bottle.constant('window', global.window);
|
||||||
|
|
||||||
|
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
|
||||||
|
bottle.decorator('ScrollToTop', withRouter);
|
||||||
|
|
||||||
|
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||||
|
bottle.decorator('MainHeader', withRouter);
|
||||||
|
|
||||||
|
bottle.serviceFactory('Home', () => Home);
|
||||||
|
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'MenuLayout',
|
||||||
|
MenuLayout,
|
||||||
|
'TagsList',
|
||||||
|
'ShortUrls',
|
||||||
|
'AsideMenu',
|
||||||
|
'CreateShortUrl',
|
||||||
|
'ShortUrlVisits'
|
||||||
|
);
|
||||||
|
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||||
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|
||||||
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
37
src/container/index.js
Normal file
37
src/container/index.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
|
import { pick } from 'ramda';
|
||||||
|
import App from '../App';
|
||||||
|
import provideCommonServices from '../common/services/provideServices';
|
||||||
|
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
||||||
|
import provideServersServices from '../servers/services/provideServices';
|
||||||
|
import provideVisitsServices from '../visits/services/provideServices';
|
||||||
|
import provideTagsServices from '../tags/services/provideServices';
|
||||||
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
|
|
||||||
|
const bottle = new Bottle();
|
||||||
|
const { container } = bottle;
|
||||||
|
|
||||||
|
const mapActionService = (map, actionName) => ({
|
||||||
|
...map,
|
||||||
|
|
||||||
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
|
[actionName]: (...args) => container[actionName](...args),
|
||||||
|
});
|
||||||
|
const connect = (propsFromState, actionServiceNames) =>
|
||||||
|
reduxConnect(
|
||||||
|
propsFromState ? pick(propsFromState) : null,
|
||||||
|
actionServiceNames.reduce(mapActionService, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
|
||||||
|
|
||||||
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
|
provideShortUrlsServices(bottle, connect);
|
||||||
|
provideServersServices(bottle, connect, withRouter);
|
||||||
|
provideTagsServices(bottle, connect);
|
||||||
|
provideVisitsServices(bottle, connect);
|
||||||
|
provideUtilsServices(bottle);
|
||||||
|
|
||||||
|
export default container;
|
||||||
13
src/container/store.js
Normal file
13
src/container/store.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import ReduxThunk from 'redux-thunk';
|
||||||
|
import { applyMiddleware, compose, createStore } from 'redux';
|
||||||
|
import reducers from '../reducers';
|
||||||
|
|
||||||
|
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||||
|
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||||
|
: compose;
|
||||||
|
|
||||||
|
const store = createStore(reducers, composeEnhancers(
|
||||||
|
applyMiddleware(ReduxThunk)
|
||||||
|
));
|
||||||
|
|
||||||
|
export default store;
|
||||||
23
src/index.js
23
src/index.js
@@ -1,28 +1,21 @@
|
|||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import { render } from 'react-dom';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
import { homepage } from '../package.json';
|
||||||
import ReduxThunk from 'redux-thunk';
|
|
||||||
import App from './App';
|
|
||||||
import './index.scss';
|
|
||||||
import ScrollToTop from './common/ScrollToTop';
|
|
||||||
import reducers from './reducers';
|
|
||||||
import registerServiceWorker from './registerServiceWorker';
|
import registerServiceWorker from './registerServiceWorker';
|
||||||
|
import container from './container';
|
||||||
|
import store from './container/store';
|
||||||
import '../node_modules/react-datepicker/dist/react-datepicker.css';
|
import '../node_modules/react-datepicker/dist/react-datepicker.css';
|
||||||
import './common/react-tagsinput.scss';
|
import './common/react-tagsinput.scss';
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
const { App, ScrollToTop } = container;
|
||||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
|
||||||
: compose;
|
|
||||||
const store = createStore(reducers, composeEnhancers(
|
|
||||||
applyMiddleware(ReduxThunk)
|
|
||||||
));
|
|
||||||
|
|
||||||
ReactDOM.render(
|
render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={homepage}>
|
||||||
<ScrollToTop>
|
<ScrollToTop>
|
||||||
<App />
|
<App />
|
||||||
</ScrollToTop>
|
</ScrollToTop>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -18,12 +18,14 @@ body,
|
|||||||
background-color: $mainColor !important;
|
background-color: $mainColor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.dropdown-item.active,
|
|
||||||
.dropdown-item:active {
|
.dropdown-item.active:not(:disabled),
|
||||||
@extend .bg-main;
|
.dropdown-item:active:not(:disabled) {
|
||||||
|
background-color: $lightGrey !important;
|
||||||
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shlink-container {
|
.shlink-container {
|
||||||
@@ -46,7 +48,6 @@ body,
|
|||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
margin-right: auto !important; // This is needed to override a third party style
|
margin: 0 auto !important;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import serversReducer from '../servers/reducers/server';
|
|||||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
||||||
import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult';
|
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||||
import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits';
|
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
||||||
|
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||||
|
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||||
import tagsListReducer from '../tags/reducers/tagsList';
|
import tagsListReducer from '../tags/reducers/tagsList';
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||||
@@ -15,9 +17,11 @@ export default combineReducers({
|
|||||||
selectedServer: selectedServerReducer,
|
selectedServer: selectedServerReducer,
|
||||||
shortUrlsList: shortUrlsListReducer,
|
shortUrlsList: shortUrlsListReducer,
|
||||||
shortUrlsListParams: shortUrlsListParamsReducer,
|
shortUrlsListParams: shortUrlsListParamsReducer,
|
||||||
shortUrlCreationResult: shortUrlCreationResultReducer,
|
shortUrlCreationResult: shortUrlCreationReducer,
|
||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlDeletion: shortUrlDeletionReducer,
|
||||||
shortUrlTags: shortUrlTagsReducer,
|
shortUrlTags: shortUrlTagsReducer,
|
||||||
|
shortUrlVisits: shortUrlVisitsReducer,
|
||||||
|
shortUrlDetail: shortUrlDetailReducer,
|
||||||
tagsList: tagsListReducer,
|
tagsList: tagsListReducer,
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: tagDeleteReducer,
|
||||||
tagEdit: tagEditReducer,
|
tagEdit: tagEditReducer,
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { assoc, dissoc, pick, pipe } from 'ramda';
|
import { assoc, dissoc, pipe } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { stateFlagTimeout } from '../utils/utils';
|
import { stateFlagTimeout } from '../utils/utils';
|
||||||
import { resetSelectedServer } from './reducers/selectedServer';
|
|
||||||
import { createServer } from './reducers/server';
|
|
||||||
import './CreateServer.scss';
|
import './CreateServer.scss';
|
||||||
import ImportServersBtn from './helpers/ImportServersBtn';
|
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
export class CreateServerComponent extends React.Component {
|
const CreateServer = (ImportServersBtn) => class CreateServer extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
createServer: PropTypes.func,
|
createServer: PropTypes.func,
|
||||||
history: PropTypes.shape({
|
history: PropTypes.shape({
|
||||||
@@ -91,11 +87,6 @@ export class CreateServerComponent extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const CreateServer = connect(
|
|
||||||
pick([ 'selectedServer' ]),
|
|
||||||
{ createServer, resetSelectedServer }
|
|
||||||
)(CreateServerComponent);
|
|
||||||
|
|
||||||
export default CreateServer;
|
export default CreateServer;
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
|
|||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import DeleteServerModal from './DeleteServerModal';
|
|
||||||
import { serverType } from './prop-types';
|
import { serverType } from './prop-types';
|
||||||
|
|
||||||
const propTypes = {
|
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
|
||||||
server: serverType,
|
static propTypes = {
|
||||||
className: PropTypes.string,
|
server: serverType,
|
||||||
};
|
className: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
export default class DeleteServerButton extends React.Component {
|
|
||||||
state = { isModalOpen: false };
|
state = { isModalOpen: false };
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -36,6 +35,6 @@ export default class DeleteServerButton extends React.Component {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
DeleteServerButton.propTypes = propTypes;
|
export default DeleteServerButton;
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { compose } from 'redux';
|
|
||||||
import { deleteServer } from './reducers/server';
|
|
||||||
import { serverType } from './prop-types';
|
import { serverType } from './prop-types';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
@@ -17,7 +13,7 @@ const propTypes = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServer, history }) => {
|
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
deleteServer(server);
|
deleteServer(server);
|
||||||
toggle();
|
toggle();
|
||||||
@@ -42,11 +38,6 @@ export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServe
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
DeleteServerModalComponent.propTypes = propTypes;
|
DeleteServerModal.propTypes = propTypes;
|
||||||
|
|
||||||
const DeleteServerModal = compose(
|
|
||||||
withRouter,
|
|
||||||
connect(null, { deleteServer })
|
|
||||||
)(DeleteServerModalComponent);
|
|
||||||
|
|
||||||
export default DeleteServerModal;
|
export default DeleteServerModal;
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
import { isEmpty, pick, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { selectServer } from '../servers/reducers/selectedServer';
|
|
||||||
import serversExporter from '../servers/services/ServersExporter';
|
|
||||||
import { listServers } from './reducers/server';
|
|
||||||
import { serverType } from './prop-types';
|
import { serverType } from './prop-types';
|
||||||
|
|
||||||
const defaultProps = {
|
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
|
||||||
serversExporter,
|
static propTypes = {
|
||||||
};
|
servers: PropTypes.object,
|
||||||
const propTypes = {
|
selectedServer: serverType,
|
||||||
servers: PropTypes.object,
|
selectServer: PropTypes.func,
|
||||||
serversExporter: PropTypes.shape({
|
listServers: PropTypes.func,
|
||||||
exportServers: PropTypes.func,
|
};
|
||||||
}),
|
|
||||||
selectedServer: serverType,
|
|
||||||
selectServer: PropTypes.func,
|
|
||||||
listServers: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ServersDropdownComponent extends React.Component {
|
|
||||||
renderServers = () => {
|
renderServers = () => {
|
||||||
const { servers, selectedServer, selectServer, serversExporter } = this.props;
|
const { servers, selectedServer, selectServer } = this.props;
|
||||||
|
|
||||||
if (isEmpty(servers)) {
|
if (isEmpty(servers)) {
|
||||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||||
@@ -68,14 +58,6 @@ export class ServersDropdownComponent extends React.Component {
|
|||||||
</UncontrolledDropdown>
|
</UncontrolledDropdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ServersDropdownComponent.defaultProps = defaultProps;
|
|
||||||
ServersDropdownComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const ServersDropdown = connect(
|
|
||||||
pick([ 'servers', 'selectedServer' ]),
|
|
||||||
{ listServers, selectServer }
|
|
||||||
)(ServersDropdownComponent);
|
|
||||||
|
|
||||||
export default ServersDropdown;
|
export default ServersDropdown;
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { assoc } from 'ramda';
|
import { assoc } from 'ramda';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { createServers } from '../reducers/server';
|
|
||||||
import serversImporter, { serversImporterType } from '../services/ServersImporter';
|
|
||||||
|
|
||||||
const defaultProps = {
|
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
|
||||||
serversImporter,
|
static defaultProps = {
|
||||||
onImport: () => ({}),
|
onImport: () => ({}),
|
||||||
};
|
};
|
||||||
const propTypes = {
|
static propTypes = {
|
||||||
onImport: PropTypes.func,
|
onImport: PropTypes.func,
|
||||||
serversImporter: serversImporterType,
|
createServers: PropTypes.func,
|
||||||
createServers: PropTypes.func,
|
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
export class ImportServersBtnComponent extends React.Component {
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.fileRef = props.fileRef || React.createRef();
|
this.fileRef = props.fileRef || React.createRef();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { serversImporter: { importServersFromFile }, onImport, createServers } = this.props;
|
const { importServersFromFile } = serversImporter;
|
||||||
|
const { onImport, createServers } = this.props;
|
||||||
const onChange = (e) =>
|
const onChange = (e) =>
|
||||||
importServersFromFile(e.target.files[0])
|
importServersFromFile(e.target.files[0])
|
||||||
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
|
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
|
||||||
@@ -56,11 +52,6 @@ export class ImportServersBtnComponent extends React.Component {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ImportServersBtnComponent.defaultProps = defaultProps;
|
|
||||||
ImportServersBtnComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
|
|
||||||
|
|
||||||
export default ImportServersBtn;
|
export default ImportServersBtn;
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { curry } from 'ramda';
|
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
|
||||||
import serversService from '../../servers/services/ServersService';
|
|
||||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
@@ -23,17 +20,13 @@ export default function reducer(state = defaultState, action) {
|
|||||||
|
|
||||||
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
|
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
|
||||||
|
|
||||||
export const _selectServer = (shlinkApiClient, serversService, serverId) => (dispatch) => {
|
export const selectServer = (serversService) => (serverId) => (dispatch) => {
|
||||||
dispatch(resetShortUrlParams());
|
dispatch(resetShortUrlParams());
|
||||||
|
|
||||||
const selectedServer = serversService.findServerById(serverId);
|
const selectedServer = serversService.findServerById(serverId);
|
||||||
|
|
||||||
shlinkApiClient.setConfig(selectedServer);
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SELECT_SERVER,
|
type: SELECT_SERVER,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectServer = curry(_selectServer)(shlinkApiClient, serversService);
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { curry } from 'ramda';
|
|
||||||
import serversService from '../services/ServersService';
|
|
||||||
|
|
||||||
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
|
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
|
||||||
|
|
||||||
export default function reducer(state = {}, action) {
|
export default function reducer(state = {}, action) {
|
||||||
@@ -12,33 +9,25 @@ export default function reducer(state = {}, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _listServers = (serversService) => ({
|
export const listServers = (serversService) => () => ({
|
||||||
type: FETCH_SERVERS,
|
type: FETCH_SERVERS,
|
||||||
servers: serversService.listServers(),
|
servers: serversService.listServers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listServers = () => _listServers(serversService);
|
export const createServer = (serversService) => (server) => {
|
||||||
|
|
||||||
export const _createServer = (serversService, server) => {
|
|
||||||
serversService.createServer(server);
|
serversService.createServer(server);
|
||||||
|
|
||||||
return _listServers(serversService);
|
return listServers(serversService)();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createServer = curry(_createServer)(serversService);
|
export const deleteServer = (serversService) => (server) => {
|
||||||
|
|
||||||
export const _deleteServer = (serversService, server) => {
|
|
||||||
serversService.deleteServer(server);
|
serversService.deleteServer(server);
|
||||||
|
|
||||||
return _listServers(serversService);
|
return listServers(serversService)();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteServer = curry(_deleteServer)(serversService);
|
export const createServers = (serversService) => (servers) => {
|
||||||
|
|
||||||
export const _createServers = (serversService, servers) => {
|
|
||||||
serversService.createServers(servers);
|
serversService.createServers(servers);
|
||||||
|
|
||||||
return _listServers(serversService);
|
return listServers(serversService)();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createServers = curry(_createServers)(serversService);
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { dissoc, head, keys, values } from 'ramda';
|
import { dissoc, head, keys, values } from 'ramda';
|
||||||
import csvjson from 'csvjson';
|
|
||||||
import serversService from './ServersService';
|
|
||||||
|
|
||||||
const saveCsv = (window, csv) => {
|
const saveCsv = (window, csv) => {
|
||||||
const { navigator, document } = window;
|
const { navigator, document } = window;
|
||||||
@@ -26,7 +24,7 @@ const saveCsv = (window, csv) => {
|
|||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ServersExporter {
|
export default class ServersExporter {
|
||||||
constructor(serversService, window, csvjson) {
|
constructor(serversService, window, csvjson) {
|
||||||
this.serversService = serversService;
|
this.serversService = serversService;
|
||||||
this.window = window;
|
this.window = window;
|
||||||
@@ -49,7 +47,3 @@ export class ServersExporter {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverExporter = new ServersExporter(serversService, global.window, csvjson);
|
|
||||||
|
|
||||||
export default serverExporter;
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import csvjson from 'csvjson';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
export const serversImporterType = PropTypes.shape({
|
export const serversImporterType = PropTypes.shape({
|
||||||
importServersFromFile: PropTypes.func,
|
importServersFromFile: PropTypes.func,
|
||||||
});
|
});
|
||||||
|
|
||||||
export class ServersImporter {
|
export default class ServersImporter {
|
||||||
constructor(csvjson) {
|
constructor(csvjson) {
|
||||||
this.csvjson = csvjson;
|
this.csvjson = csvjson;
|
||||||
}
|
}
|
||||||
@@ -28,7 +27,3 @@ export class ServersImporter {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const serversImporter = new ServersImporter(csvjson);
|
|
||||||
|
|
||||||
export default serversImporter;
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { assoc, dissoc, reduce } from 'ramda';
|
import { assoc, dissoc, reduce } from 'ramda';
|
||||||
import storage from '../../utils/Storage';
|
|
||||||
|
|
||||||
const SERVERS_STORAGE_KEY = 'servers';
|
const SERVERS_STORAGE_KEY = 'servers';
|
||||||
|
|
||||||
export class ServersService {
|
export default class ServersService {
|
||||||
constructor(storage) {
|
constructor(storage) {
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
}
|
}
|
||||||
@@ -30,7 +29,3 @@ export class ServersService {
|
|||||||
dissoc(server.id, this.listServers())
|
dissoc(server.id, this.listServers())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const serversService = new ServersService(storage);
|
|
||||||
|
|
||||||
export default serversService;
|
|
||||||
|
|||||||
46
src/servers/services/provideServices.js
Normal file
46
src/servers/services/provideServices.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import csvjson from 'csvjson';
|
||||||
|
import CreateServer from '../CreateServer';
|
||||||
|
import ServersDropdown from '../ServersDropdown';
|
||||||
|
import DeleteServerModal from '../DeleteServerModal';
|
||||||
|
import DeleteServerButton from '../DeleteServerButton';
|
||||||
|
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||||
|
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||||
|
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
|
||||||
|
import ServersImporter from './ServersImporter';
|
||||||
|
import ServersService from './ServersService';
|
||||||
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
|
const provideServices = (bottle, connect, withRouter) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn');
|
||||||
|
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||||
|
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||||
|
bottle.decorator('DeleteServerModal', withRouter);
|
||||||
|
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
||||||
|
|
||||||
|
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||||
|
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
|
||||||
|
|
||||||
|
// Services
|
||||||
|
bottle.constant('csvjson', csvjson);
|
||||||
|
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
||||||
|
bottle.service('ServersService', ServersService, 'Storage');
|
||||||
|
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('selectServer', selectServer, 'ServersService');
|
||||||
|
bottle.serviceFactory('createServer', createServer, 'ServersService');
|
||||||
|
bottle.serviceFactory('createServers', createServers, 'ServersService');
|
||||||
|
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService');
|
||||||
|
bottle.serviceFactory('listServers', listServers, 'ServersService');
|
||||||
|
|
||||||
|
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown';
|
import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown';
|
||||||
import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
|
import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda';
|
import { assoc, dissoc, isNil, pipe, replace, trim } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Collapse } from 'reactstrap';
|
import { Collapse } from 'reactstrap';
|
||||||
import DateInput from '../common/DateInput';
|
import * as PropTypes from 'prop-types';
|
||||||
import TagsSelector from '../utils/TagsSelector';
|
import DateInput from '../utils/DateInput';
|
||||||
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
|
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
|
||||||
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult';
|
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
||||||
|
|
||||||
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
|
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||||
|
|
||||||
|
const CreateShortUrl = (TagsSelector) => class CreateShortUrl extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
createShortUrl: PropTypes.func,
|
||||||
|
shortUrlCreationResult: createShortUrlResultType,
|
||||||
|
resetCreateShortUrl: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
export class CreateShortUrlComponent extends React.Component {
|
|
||||||
state = {
|
state = {
|
||||||
longUrl: '',
|
longUrl: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -24,27 +32,31 @@ export class CreateShortUrlComponent extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
||||||
|
|
||||||
const changeTags = (tags) => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) });
|
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
|
||||||
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
||||||
<input
|
<div className="form-group">
|
||||||
className="form-control"
|
<input
|
||||||
type={type}
|
className="form-control"
|
||||||
placeholder={placeholder}
|
id={id}
|
||||||
value={this.state[id]}
|
type={type}
|
||||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
placeholder={placeholder}
|
||||||
{...props}
|
value={this.state[id]}
|
||||||
/>
|
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
const createDateInput = (id, placeholder, props = {}) => (
|
const renderDateInput = (id, placeholder, props = {}) => (
|
||||||
<DateInput
|
<div className="form-group">
|
||||||
selected={this.state[id]}
|
<DateInput
|
||||||
placeholderText={placeholder}
|
selected={this.state[id]}
|
||||||
isClearable
|
placeholderText={placeholder}
|
||||||
onChange={(date) => this.setState({ [id]: date })}
|
isClearable
|
||||||
{...props}
|
onChange={(date) => this.setState({ [id]: date })}
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
|
||||||
const save = (e) => {
|
const save = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
createShortUrl(pipe(
|
createShortUrl(pipe(
|
||||||
@@ -75,20 +87,12 @@ export class CreateShortUrlComponent extends React.Component {
|
|||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-6">
|
<div className="col-sm-6">
|
||||||
<div className="form-group">
|
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-6">
|
<div className="col-sm-6">
|
||||||
<div className="form-group">
|
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
||||||
{createDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
{createDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
@@ -116,11 +120,6 @@ export class CreateShortUrlComponent extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const CreateShortUrl = connect(pick([ 'shortUrlCreationResult' ]), {
|
|
||||||
createShortUrl,
|
|
||||||
resetCreateShortUrl,
|
|
||||||
})(CreateShortUrlComponent);
|
|
||||||
|
|
||||||
export default CreateShortUrl;
|
export default CreateShortUrl;
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export default function Paginator({ paginator = {}, serverId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderPages = () =>
|
const renderPages = () =>
|
||||||
range(1, pagesCount + 1).map((i) => (
|
range(1, pagesCount + 1).map((pageNumber) => (
|
||||||
<PaginationItem key={i} active={currentPage === i}>
|
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
tag={Link}
|
tag={Link}
|
||||||
to={`/server/${serverId}/list-short-urls/${i}`}
|
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
||||||
>
|
>
|
||||||
{i}
|
{pageNumber}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,55 +1,56 @@
|
|||||||
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
|
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { isEmpty } from 'ramda';
|
||||||
import { isEmpty, pick } from 'ramda';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Tag from '../utils/Tag';
|
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { listShortUrls } from './reducers/shortUrlsList';
|
import Tag from '../tags/helpers/Tag';
|
||||||
import './SearchBar.scss';
|
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||||
|
import './SearchBar.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
listShortUrls: PropTypes.func,
|
listShortUrls: PropTypes.func,
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
shortUrlsListParams: shortUrlsListParamsType,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
|
const SearchBar = (colorGenerator) => {
|
||||||
const selectedTags = shortUrlsListParams.tags || [];
|
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
||||||
|
const selectedTags = shortUrlsListParams.tags || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="serach-bar-container">
|
<div className="serach-bar-container">
|
||||||
<SearchField onChange={
|
<SearchField onChange={
|
||||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
{!isEmpty(selectedTags) && (
|
||||||
<h4 className="search-bar__selected-tag mt-2">
|
<h4 className="search-bar__selected-tag mt-2">
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||||
|
|
||||||
{selectedTags.map((tag) => (
|
{selectedTags.map((tag) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={tag}
|
colorGenerator={colorGenerator}
|
||||||
text={tag}
|
key={tag}
|
||||||
clearable
|
text={tag}
|
||||||
onClose={() => listShortUrls(
|
clearable
|
||||||
{
|
onClose={() => listShortUrls(
|
||||||
...shortUrlsListParams,
|
{
|
||||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
...shortUrlsListParams,
|
||||||
}
|
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||||
)}
|
}
|
||||||
/>
|
)}
|
||||||
))}
|
/>
|
||||||
</h4>
|
))}
|
||||||
)}
|
</h4>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
SearchBarComponent.propTypes = propTypes;
|
SearchBar.propTypes = propTypes;
|
||||||
|
|
||||||
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent);
|
return SearchBar;
|
||||||
|
};
|
||||||
|
|
||||||
export default SearchBar;
|
export default SearchBar;
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
|
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
|
||||||
import { isEmpty, mapObjIndexed, pick } from 'ramda';
|
|
||||||
import React from 'react';
|
|
||||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
|
||||||
import Moment from 'react-moment';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import DateInput from '../common/DateInput';
|
|
||||||
import {
|
|
||||||
processOsStats,
|
|
||||||
processBrowserStats,
|
|
||||||
processCountriesStats,
|
|
||||||
processReferrersStats,
|
|
||||||
} from '../visits/services/VisitsParser';
|
|
||||||
import MutedMessage from '../utils/MuttedMessage';
|
|
||||||
import ExternalLink from '../utils/ExternalLink';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
|
|
||||||
import './ShortUrlVisits.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
processOsStats: PropTypes.func,
|
|
||||||
processBrowserStats: PropTypes.func,
|
|
||||||
processCountriesStats: PropTypes.func,
|
|
||||||
processReferrersStats: PropTypes.func,
|
|
||||||
match: PropTypes.object,
|
|
||||||
getShortUrlVisits: PropTypes.func,
|
|
||||||
selectedServer: serverType,
|
|
||||||
shortUrlVisits: shortUrlVisitsType,
|
|
||||||
};
|
|
||||||
const defaultProps = {
|
|
||||||
processOsStats,
|
|
||||||
processBrowserStats,
|
|
||||||
processCountriesStats,
|
|
||||||
processReferrersStats,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ShortUrlsVisitsComponent extends React.Component {
|
|
||||||
state = { startDate: undefined, endDate: undefined };
|
|
||||||
loadVisits = () => {
|
|
||||||
const { match: { params }, getShortUrlVisits } = this.props;
|
|
||||||
|
|
||||||
getShortUrlVisits(params.shortCode, mapObjIndexed(
|
|
||||||
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
|
|
||||||
this.state
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.loadVisits();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
match: { params },
|
|
||||||
selectedServer,
|
|
||||||
processOsStats,
|
|
||||||
processBrowserStats,
|
|
||||||
processCountriesStats,
|
|
||||||
processReferrersStats,
|
|
||||||
shortUrlVisits: { visits, loading, error, shortUrl },
|
|
||||||
} = this.props;
|
|
||||||
const serverUrl = selectedServer ? selectedServer.url : '';
|
|
||||||
const shortLink = `${serverUrl}/${params.shortCode}`;
|
|
||||||
const generateGraphData = (stats, label, isBarChart) => ({
|
|
||||||
labels: Object.keys(stats),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label,
|
|
||||||
data: Object.values(stats),
|
|
||||||
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
|
||||||
'#97BBCD',
|
|
||||||
'#DCDCDC',
|
|
||||||
'#F7464A',
|
|
||||||
'#46BFBD',
|
|
||||||
'#FDB45C',
|
|
||||||
'#949FB1',
|
|
||||||
'#4D5360',
|
|
||||||
],
|
|
||||||
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
|
|
||||||
borderWidth: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const renderGraphCard = (title, stats, isBarChart, label) => (
|
|
||||||
<div className="col-md-6">
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader>{title}</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
{!isBarChart && (
|
|
||||||
<Doughnut
|
|
||||||
data={generateGraphData(stats, label || title, isBarChart)}
|
|
||||||
options={{
|
|
||||||
legend: {
|
|
||||||
position: 'right',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isBarChart && (
|
|
||||||
<HorizontalBar
|
|
||||||
data={generateGraphData(stats, label || title, isBarChart)}
|
|
||||||
options={{
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const renderContent = () => {
|
|
||||||
if (loading) {
|
|
||||||
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className="mt-4" body inverse color="danger">
|
|
||||||
An error occurred while loading visits :(
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmpty(visits)) {
|
|
||||||
return <MutedMessage>There have been no visits matching current filter :(</MutedMessage>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="row">
|
|
||||||
{renderGraphCard('Operating systems', processOsStats(visits), false)}
|
|
||||||
{renderGraphCard('Browsers', processBrowserStats(visits), false)}
|
|
||||||
{renderGraphCard('Countries', processCountriesStats(visits), true, 'Visits')}
|
|
||||||
{renderGraphCard('Referrers', processReferrersStats(visits), true, 'Visits')}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCreated = () => (
|
|
||||||
<span>
|
|
||||||
<b id="created"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
|
|
||||||
<UncontrolledTooltip placement="bottom" target="created">
|
|
||||||
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
|
||||||
</UncontrolledTooltip>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shlink-container">
|
|
||||||
<header>
|
|
||||||
<Card className="bg-light">
|
|
||||||
<CardBody>
|
|
||||||
<h2>
|
|
||||||
{
|
|
||||||
shortUrl.visitsCount &&
|
|
||||||
<span className="badge badge-main float-right">Visits: {shortUrl.visitsCount}</span>
|
|
||||||
}
|
|
||||||
Visit stats for <ExternalLink href={shortLink}>{shortLink}</ExternalLink>
|
|
||||||
</h2>
|
|
||||||
<hr />
|
|
||||||
{shortUrl.dateCreated && (
|
|
||||||
<div>
|
|
||||||
Created:
|
|
||||||
|
|
||||||
{loading && <small>Loading...</small>}
|
|
||||||
{!loading && renderCreated()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
Long URL:
|
|
||||||
|
|
||||||
{loading && <small>Loading...</small>}
|
|
||||||
{!loading && <ExternalLink href={shortUrl.longUrl}>{shortUrl.longUrl}</ExternalLink>}
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mt-4">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
|
||||||
<DateInput
|
|
||||||
selected={this.state.startDate}
|
|
||||||
placeholderText="Since"
|
|
||||||
isClearable
|
|
||||||
onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
|
||||||
<DateInput
|
|
||||||
selected={this.state.endDate}
|
|
||||||
placeholderText="Until"
|
|
||||||
isClearable
|
|
||||||
className="short-url-visits__date-input"
|
|
||||||
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
{renderContent()}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ShortUrlsVisitsComponent.propTypes = propTypes;
|
|
||||||
ShortUrlsVisitsComponent.defaultProps = defaultProps;
|
|
||||||
|
|
||||||
const ShortUrlsVisits = connect(
|
|
||||||
pick([ 'selectedServer', 'shortUrlVisits' ]),
|
|
||||||
{ getShortUrlVisits }
|
|
||||||
)(ShortUrlsVisitsComponent);
|
|
||||||
|
|
||||||
export default ShortUrlsVisits;
|
|
||||||
@@ -1,27 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { assoc } from 'ramda';
|
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
import SearchBar from './SearchBar';
|
|
||||||
import ShortUrlsList from './ShortUrlsList';
|
|
||||||
|
|
||||||
export function ShortUrlsComponent(props) {
|
const ShortUrls = (SearchBar, ShortUrlsList) => (props) => {
|
||||||
const { match: { params } } = props;
|
const { match: { params }, shortUrlsList } = props;
|
||||||
|
const { page, serverId } = params;
|
||||||
|
const { data = [], pagination } = shortUrlsList;
|
||||||
|
|
||||||
// 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 = `${params.serverId}_${params.page}`;
|
const urlsListKey = `${serverId}_${page}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shlink-container">
|
<div className="shlink-container">
|
||||||
<div className="form-group"><SearchBar /></div>
|
<div className="form-group"><SearchBar /></div>
|
||||||
<ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} />
|
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||||
<Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} />
|
<Paginator paginator={pagination} serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const ShortUrls = connect(
|
|
||||||
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
|
|
||||||
)(ShortUrlsComponent);
|
|
||||||
|
|
||||||
export default ShortUrls;
|
export default ShortUrls;
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
|
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
|
||||||
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
|
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import { head, isEmpty, pick, toPairs, keys, values } from 'ramda';
|
import { head, isEmpty, keys, values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList';
|
import { determineOrderDir } from '../utils/utils';
|
||||||
import './ShortUrlsList.scss';
|
import { shortUrlType } from './reducers/shortUrlsList';
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||||
|
import './ShortUrlsList.scss';
|
||||||
|
|
||||||
const SORTABLE_FIELDS = {
|
const SORTABLE_FIELDS = {
|
||||||
dateCreated: 'Created at',
|
dateCreated: 'Created at',
|
||||||
@@ -20,18 +19,19 @@ const SORTABLE_FIELDS = {
|
|||||||
visits: 'Visits',
|
visits: 'Visits',
|
||||||
};
|
};
|
||||||
|
|
||||||
const propTypes = {
|
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
|
||||||
listShortUrls: PropTypes.func,
|
static propTypes = {
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
listShortUrls: PropTypes.func,
|
||||||
match: PropTypes.object,
|
resetShortUrlParams: PropTypes.func,
|
||||||
location: PropTypes.object,
|
shortUrlsListParams: shortUrlsListParamsType,
|
||||||
loading: PropTypes.bool,
|
match: PropTypes.object,
|
||||||
error: PropTypes.bool,
|
location: PropTypes.object,
|
||||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
loading: PropTypes.bool,
|
||||||
selectedServer: serverType,
|
error: PropTypes.bool,
|
||||||
};
|
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||||
|
selectedServer: serverType,
|
||||||
|
};
|
||||||
|
|
||||||
export class ShortUrlsListComponent extends React.Component {
|
|
||||||
refreshList = (extraParams) => {
|
refreshList = (extraParams) => {
|
||||||
const { listShortUrls, shortUrlsListParams } = this.props;
|
const { listShortUrls, shortUrlsListParams } = this.props;
|
||||||
|
|
||||||
@@ -40,25 +40,13 @@ export class ShortUrlsListComponent extends React.Component {
|
|||||||
...extraParams,
|
...extraParams,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
determineOrderDir = (field) => {
|
handleOrderBy = (orderField, orderDir) => {
|
||||||
if (this.state.orderField !== field) {
|
this.setState({ orderField, orderDir });
|
||||||
return 'ASC';
|
this.refreshList({ orderBy: { [orderField]: orderDir } });
|
||||||
}
|
|
||||||
|
|
||||||
const newOrderMap = {
|
|
||||||
ASC: 'DESC',
|
|
||||||
DESC: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.state.orderDir ? newOrderMap[this.state.orderDir] : 'ASC';
|
|
||||||
};
|
};
|
||||||
orderBy = (field) => {
|
orderByColumn = (columnName) => () =>
|
||||||
const newOrderDir = this.determineOrderDir(field);
|
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
|
||||||
|
renderOrderIcon = (field) => {
|
||||||
this.setState({ orderField: newOrderDir !== undefined ? field : undefined, orderDir: newOrderDir });
|
|
||||||
this.refreshList({ orderBy: { [field]: newOrderDir } });
|
|
||||||
};
|
|
||||||
renderOrderIcon = (field, className = 'short-urls-list__header-icon') => {
|
|
||||||
if (this.state.orderField !== field) {
|
if (this.state.orderField !== field) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -66,7 +54,7 @@ export class ShortUrlsListComponent extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||||
className={className}
|
className="short-urls-list__header-icon"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -89,6 +77,12 @@ export class ShortUrlsListComponent extends React.Component {
|
|||||||
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags });
|
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
const { resetShortUrlParams } = this.props;
|
||||||
|
|
||||||
|
resetShortUrlParams();
|
||||||
|
}
|
||||||
|
|
||||||
renderShortUrls() {
|
renderShortUrls() {
|
||||||
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
||||||
|
|
||||||
@@ -119,50 +113,37 @@ export class ShortUrlsListComponent extends React.Component {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMobileOrderingControls() {
|
|
||||||
return (
|
|
||||||
<div className="d-block d-md-none mb-3">
|
|
||||||
<UncontrolledDropdown>
|
|
||||||
<DropdownToggle caret className="btn-block">
|
|
||||||
Order by
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu className="short-urls-list__order-dropdown">
|
|
||||||
{toPairs(SORTABLE_FIELDS).map(([ key, value ]) => (
|
|
||||||
<DropdownItem key={key} active={this.state.orderField === key} onClick={() => this.orderBy(key)}>
|
|
||||||
{value}
|
|
||||||
{this.renderOrderIcon(key, 'short-urls-list__header-icon--mobile')}
|
|
||||||
</DropdownItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu>
|
|
||||||
</UncontrolledDropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{this.renderMobileOrderingControls()}
|
<div className="d-block d-md-none mb-3">
|
||||||
|
<SortingDropdown
|
||||||
|
items={SORTABLE_FIELDS}
|
||||||
|
orderField={this.state.orderField}
|
||||||
|
orderDir={this.state.orderDir}
|
||||||
|
onChange={this.handleOrderBy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<table className="table table-striped table-hover">
|
<table className="table table-striped table-hover">
|
||||||
<thead className="short-urls-list__header">
|
<thead className="short-urls-list__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
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.orderBy('dateCreated')}
|
onClick={this.orderByColumn('dateCreated')}
|
||||||
>
|
>
|
||||||
{this.renderOrderIcon('dateCreated')}
|
{this.renderOrderIcon('dateCreated')}
|
||||||
Created at
|
Created at
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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.orderBy('shortCode')}
|
onClick={this.orderByColumn('shortCode')}
|
||||||
>
|
>
|
||||||
{this.renderOrderIcon('shortCode')}
|
{this.renderOrderIcon('shortCode')}
|
||||||
Short URL
|
Short URL
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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.orderBy('originalUrl')}
|
onClick={this.orderByColumn('originalUrl')}
|
||||||
>
|
>
|
||||||
{this.renderOrderIcon('originalUrl')}
|
{this.renderOrderIcon('originalUrl')}
|
||||||
Long URL
|
Long URL
|
||||||
@@ -170,7 +151,7 @@ export class ShortUrlsListComponent extends React.Component {
|
|||||||
<th className="short-urls-list__header-cell">Tags</th>
|
<th className="short-urls-list__header-cell">Tags</th>
|
||||||
<th
|
<th
|
||||||
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.orderBy('visits')}
|
onClick={this.orderByColumn('visits')}
|
||||||
>
|
>
|
||||||
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
|
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -184,13 +165,6 @@ export class ShortUrlsListComponent extends React.Component {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ShortUrlsListComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const ShortUrlsList = connect(
|
|
||||||
pick([ 'selectedServer', 'shortUrlsListParams' ]),
|
|
||||||
{ listShortUrls }
|
|
||||||
)(ShortUrlsListComponent);
|
|
||||||
|
|
||||||
export default ShortUrlsList;
|
export default ShortUrlsList;
|
||||||
|
|||||||
@@ -14,15 +14,6 @@
|
|||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-list__header-icon--mobile {
|
|
||||||
margin: 3.5px 0 0;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-list__header-cell--with-action {
|
.short-urls-list__header-cell--with-action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-list__order-dropdown {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import React from 'react';
|
|||||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||||
import { Card, CardBody, Tooltip } from 'reactstrap';
|
import { Card, CardBody, Tooltip } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { createShortUrlResultType } from '../reducers/shortUrlCreationResult';
|
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
|
||||||
import { stateFlagTimeout } from '../../utils/utils';
|
import { stateFlagTimeout } from '../../utils/utils';
|
||||||
import './CreateShortUrlResult.scss';
|
import './CreateShortUrlResult.scss';
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
resetCreateShortUrl: PropTypes.func,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
result: createShortUrlResultType,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class CreateShortUrlResult extends React.Component {
|
export default class CreateShortUrlResult extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
resetCreateShortUrl: PropTypes.func,
|
||||||
|
error: PropTypes.bool,
|
||||||
|
result: createShortUrlResultType,
|
||||||
|
};
|
||||||
|
|
||||||
state = { showCopyTooltip: false };
|
state = { showCopyTooltip: false };
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -62,5 +62,3 @@ export default class CreateShortUrlResult extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateShortUrlResult.propTypes = propTypes;
|
|
||||||
|
|||||||
89
src/short-urls/helpers/DeleteShortUrlModal.js
Normal file
89
src/short-urls/helpers/DeleteShortUrlModal.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { identity } from 'ramda';
|
||||||
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
|
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
|
||||||
|
|
||||||
|
export default class DeleteShortUrlModal extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
shortUrl: shortUrlType,
|
||||||
|
toggle: PropTypes.func,
|
||||||
|
isOpen: PropTypes.bool,
|
||||||
|
shortUrlDeletion: shortUrlDeletionType,
|
||||||
|
deleteShortUrl: PropTypes.func,
|
||||||
|
resetDeleteShortUrl: PropTypes.func,
|
||||||
|
shortUrlDeleted: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = { inputValue: '' };
|
||||||
|
handleDeleteUrl = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props;
|
||||||
|
const { shortCode } = shortUrl;
|
||||||
|
|
||||||
|
deleteShortUrl(shortCode)
|
||||||
|
.then(() => {
|
||||||
|
shortUrlDeleted(shortCode);
|
||||||
|
toggle();
|
||||||
|
})
|
||||||
|
.catch(identity);
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
const { resetDeleteShortUrl } = this.props;
|
||||||
|
|
||||||
|
resetDeleteShortUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
|
||||||
|
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||||
|
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
|
||||||
|
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
|
<form onSubmit={this.handleDeleteUrl}>
|
||||||
|
<ModalHeader toggle={toggle}>
|
||||||
|
<span className="text-danger">Delete short URL</span>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||||
|
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Insert the short code of the URL"
|
||||||
|
value={this.state.inputValue}
|
||||||
|
onChange={(e) => this.setState({ inputValue: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasThresholdError && (
|
||||||
|
<div className="p-2 mt-2 bg-warning text-center">
|
||||||
|
This short URL has received too many visits and therefore, it cannot be deleted
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasErrorOtherThanThreshold && (
|
||||||
|
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||||
|
Something went wrong while deleting the URL :(
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-danger"
|
||||||
|
disabled={this.state.inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
|
||||||
|
>
|
||||||
|
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { pick } from 'ramda';
|
|
||||||
import TagsSelector from '../../utils/TagsSelector';
|
|
||||||
import {
|
|
||||||
editShortUrlTags,
|
|
||||||
resetShortUrlsTags,
|
|
||||||
shortUrlTagsType,
|
|
||||||
shortUrlTagsEdited,
|
|
||||||
} from '../reducers/shortUrlTags';
|
|
||||||
import ExternalLink from '../../utils/ExternalLink';
|
import ExternalLink from '../../utils/ExternalLink';
|
||||||
|
import { shortUrlTagsType } from '../reducers/shortUrlTags';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
|
|
||||||
const propTypes = {
|
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
|
||||||
isOpen: PropTypes.bool.isRequired,
|
static propTypes = {
|
||||||
toggle: PropTypes.func.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
url: PropTypes.string.isRequired,
|
toggle: PropTypes.func.isRequired,
|
||||||
shortUrl: shortUrlType.isRequired,
|
url: PropTypes.string.isRequired,
|
||||||
shortUrlTags: shortUrlTagsType,
|
shortUrl: shortUrlType.isRequired,
|
||||||
editShortUrlTags: PropTypes.func,
|
shortUrlTags: shortUrlTagsType,
|
||||||
shortUrlTagsEdited: PropTypes.func,
|
editShortUrlTags: PropTypes.func,
|
||||||
resetShortUrlsTags: PropTypes.func,
|
shortUrlTagsEdited: PropTypes.func,
|
||||||
};
|
resetShortUrlsTags: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
export class EditTagsModalComponent extends React.Component {
|
|
||||||
saveTags = () => {
|
saveTags = () => {
|
||||||
const { editShortUrlTags, shortUrl, toggle } = this.props;
|
const { editShortUrlTags, shortUrl, toggle } = this.props;
|
||||||
|
|
||||||
@@ -40,8 +32,8 @@ export class EditTagsModalComponent extends React.Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { shortUrlTagsEdited, shortUrl } = this.props;
|
const { shortUrlTagsEdited, shortUrl, shortUrlTags } = this.props;
|
||||||
const { tags } = this.state;
|
const { tags } = shortUrlTags;
|
||||||
|
|
||||||
shortUrlTagsEdited(shortUrl.shortCode, tags);
|
shortUrlTagsEdited(shortUrl.shortCode, tags);
|
||||||
};
|
};
|
||||||
@@ -88,13 +80,6 @@ export class EditTagsModalComponent extends React.Component {
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
EditTagsModalComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const EditTagsModal = connect(
|
|
||||||
pick([ 'shortUrlTags' ]),
|
|
||||||
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }
|
|
||||||
)(EditTagsModalComponent);
|
|
||||||
|
|
||||||
export default EditTagsModal;
|
export default EditTagsModal;
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ const propTypes = {
|
|||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PreviewModal({ url, toggle, isOpen }) {
|
const PreviewModal = ({ url, toggle, isOpen }) => (
|
||||||
return (
|
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
||||||
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
<ModalHeader toggle={toggle}>
|
||||||
<ModalHeader toggle={toggle}>
|
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
||||||
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
</ModalHeader>
|
||||||
</ModalHeader>
|
<ModalBody>
|
||||||
<ModalBody>
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<p className="preview-modal__loader">Loading...</p>
|
||||||
<p className="preview-modal__loader">Loading...</p>
|
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
|
||||||
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
|
</div>
|
||||||
</div>
|
</ModalBody>
|
||||||
</ModalBody>
|
</Modal>
|
||||||
</Modal>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PreviewModal.propTypes = propTypes;
|
PreviewModal.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default PreviewModal;
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ const propTypes = {
|
|||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QrCodeModal({ url, toggle, isOpen }) {
|
const QrCodeModal = ({ url, toggle, isOpen }) => (
|
||||||
return (
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<ModalHeader toggle={toggle}>
|
||||||
<ModalHeader toggle={toggle}>
|
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
||||||
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
</ModalHeader>
|
||||||
</ModalHeader>
|
<ModalBody>
|
||||||
<ModalBody>
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
|
||||||
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
|
</div>
|
||||||
</div>
|
</ModalBody>
|
||||||
</ModalBody>
|
</Modal>
|
||||||
</Modal>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
QrCodeModal.propTypes = propTypes;
|
QrCodeModal.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default QrCodeModal;
|
||||||
|
|||||||
@@ -2,23 +2,22 @@ import { isEmpty } from 'ramda';
|
|||||||
import React from 'react';
|
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 Tag from '../../utils/Tag';
|
|
||||||
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
|
||||||
import { serverType } from '../../servers/prop-types';
|
import { serverType } from '../../servers/prop-types';
|
||||||
import ExternalLink from '../../utils/ExternalLink';
|
import ExternalLink from '../../utils/ExternalLink';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
import { stateFlagTimeout } from '../../utils/utils';
|
import { stateFlagTimeout } from '../../utils/utils';
|
||||||
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu';
|
import Tag from '../../tags/helpers/Tag';
|
||||||
import './ShortUrlsRow.scss';
|
import './ShortUrlsRow.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const ShortUrlsRow = (ShortUrlsRowMenu, colorGenerator) => class ShortUrlsRow extends React.Component {
|
||||||
refreshList: PropTypes.func,
|
static propTypes = {
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
refreshList: PropTypes.func,
|
||||||
selectedServer: serverType,
|
shortUrlsListParams: shortUrlsListParamsType,
|
||||||
shortUrl: shortUrlType,
|
selectedServer: serverType,
|
||||||
};
|
shortUrl: shortUrlType,
|
||||||
|
};
|
||||||
|
|
||||||
export class ShortUrlsRow extends React.Component {
|
|
||||||
state = { copiedToClipboard: false };
|
state = { copiedToClipboard: false };
|
||||||
|
|
||||||
renderTags(tags) {
|
renderTags(tags) {
|
||||||
@@ -31,6 +30,7 @@ export class ShortUrlsRow extends React.Component {
|
|||||||
|
|
||||||
return tags.map((tag) => (
|
return tags.map((tag) => (
|
||||||
<Tag
|
<Tag
|
||||||
|
colorGenerator={colorGenerator}
|
||||||
key={tag}
|
key={tag}
|
||||||
text={tag}
|
text={tag}
|
||||||
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
||||||
@@ -72,6 +72,6 @@ export class ShortUrlsRow extends React.Component {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ShortUrlsRow.propTypes = propTypes;
|
export default ShortUrlsRow;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
|
|||||||
import pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie';
|
import pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie';
|
||||||
import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV';
|
import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV';
|
||||||
import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode';
|
import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode';
|
||||||
|
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
|
||||||
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 { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||||
@@ -15,40 +16,43 @@ import { shortUrlType } from '../reducers/shortUrlsList';
|
|||||||
import PreviewModal from './PreviewModal';
|
import PreviewModal from './PreviewModal';
|
||||||
import QrCodeModal from './QrCodeModal';
|
import QrCodeModal from './QrCodeModal';
|
||||||
import './ShortUrlsRowMenu.scss';
|
import './ShortUrlsRowMenu.scss';
|
||||||
import EditTagsModal from './EditTagsModal';
|
|
||||||
|
|
||||||
const propTypes = {
|
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
|
||||||
completeShortUrl: PropTypes.string,
|
static propTypes = {
|
||||||
onCopyToClipboard: PropTypes.func,
|
completeShortUrl: PropTypes.string,
|
||||||
selectedServer: serverType,
|
onCopyToClipboard: PropTypes.func,
|
||||||
shortUrl: shortUrlType,
|
selectedServer: serverType,
|
||||||
};
|
shortUrl: shortUrlType,
|
||||||
|
};
|
||||||
|
|
||||||
export class ShortUrlsRowMenu extends React.Component {
|
|
||||||
state = {
|
state = {
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
isQrModalOpen: false,
|
isQrModalOpen: false,
|
||||||
isPreviewOpen: false,
|
isPreviewOpen: false,
|
||||||
isTagsModalOpen: false,
|
isTagsModalOpen: false,
|
||||||
|
isDeleteModalOpen: false,
|
||||||
};
|
};
|
||||||
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
|
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props;
|
const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props;
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
const serverId = selectedServer ? selectedServer.id : '';
|
||||||
const toggleQrCode = () => this.setState(({ isQrModalOpen }) => ({ isQrModalOpen: !isQrModalOpen }));
|
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
|
||||||
const togglePreview = () => this.setState(({ isPreviewOpen }) => ({ isPreviewOpen: !isPreviewOpen }));
|
const toggleQrCode = toggleModal('isQrModalOpen');
|
||||||
const toggleTags = () => this.setState(({ isTagsModalOpen }) => ({ isTagsModalOpen: !isTagsModalOpen }));
|
const togglePreview = toggleModal('isPreviewOpen');
|
||||||
|
const toggleTags = toggleModal('isTagsModalOpen');
|
||||||
|
const toggleDelete = toggleModal('isDeleteModalOpen');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen} direction="left">
|
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
|
||||||
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
|
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={menuIcon} />
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu>
|
<DropdownMenu right>
|
||||||
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
|
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
|
||||||
<FontAwesomeIcon icon={pieChartIcon} /> Visit Stats
|
<FontAwesomeIcon icon={pieChartIcon} /> Visit stats
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem onClick={toggleTags}>
|
<DropdownItem onClick={toggleTags}>
|
||||||
<FontAwesomeIcon icon={tagsIcon} /> Edit tags
|
<FontAwesomeIcon icon={tagsIcon} /> Edit tags
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
@@ -59,6 +63,15 @@ export class ShortUrlsRowMenu extends React.Component {
|
|||||||
toggle={toggleTags}
|
toggle={toggleTags}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||||
|
<FontAwesomeIcon icon={deleteIcon} /> Delete short URL
|
||||||
|
</DropdownItem>
|
||||||
|
<DeleteShortUrlModal
|
||||||
|
shortUrl={shortUrl}
|
||||||
|
isOpen={this.state.isDeleteModalOpen}
|
||||||
|
toggle={toggleDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
<DropdownItem onClick={togglePreview}>
|
<DropdownItem onClick={togglePreview}>
|
||||||
@@ -90,6 +103,6 @@ export class ShortUrlsRowMenu extends React.Component {
|
|||||||
</ButtonDropdown>
|
</ButtonDropdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ShortUrlsRowMenu.propTypes = propTypes;
|
export default ShortUrlsRowMenu;
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
.short-urls-row-menu__dropdown-toggle:before {
|
@import '../../utils/base';
|
||||||
|
|
||||||
|
.short-urls-row-menu__dropdown-toggle:after {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-row-menu__dropdown-toggle--hidden {
|
.short-urls-row-menu__dropdown-toggle--hidden {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
||||||
|
color: $dangerColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: $dangerColor !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { curry } from 'ramda';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||||
const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
||||||
const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
||||||
const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
export const createShortUrlResultType = PropTypes.shape({
|
export const createShortUrlResultType = PropTypes.shape({
|
||||||
@@ -29,6 +27,7 @@ export default function reducer(state = defaultState, action) {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
saving: true,
|
saving: true,
|
||||||
|
error: false,
|
||||||
};
|
};
|
||||||
case CREATE_SHORT_URL_ERROR:
|
case CREATE_SHORT_URL_ERROR:
|
||||||
return {
|
return {
|
||||||
@@ -49,9 +48,12 @@ export default function reducer(state = defaultState, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => {
|
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
|
||||||
dispatch({ type: CREATE_SHORT_URL_START });
|
dispatch({ type: CREATE_SHORT_URL_START });
|
||||||
|
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
const shlinkApiClient = buildShlinkApiClient(selectedServer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await shlinkApiClient.createShortUrl(data);
|
const result = await shlinkApiClient.createShortUrl(data);
|
||||||
|
|
||||||
@@ -61,6 +63,4 @@ export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createShortUrl = curry(_createShortUrl)(shlinkApiClient);
|
|
||||||
|
|
||||||
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });
|
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });
|
||||||
75
src/short-urls/reducers/shortUrlDeletion.js
Normal file
75
src/short-urls/reducers/shortUrlDeletion.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
|
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||||
|
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
|
||||||
|
export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
|
||||||
|
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
|
||||||
|
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
|
||||||
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
|
export const shortUrlDeletionType = PropTypes.shape({
|
||||||
|
shortCode: PropTypes.string.isRequired,
|
||||||
|
loading: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.bool.isRequired,
|
||||||
|
errorData: PropTypes.shape({
|
||||||
|
error: PropTypes.string,
|
||||||
|
message: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultState = {
|
||||||
|
shortCode: '',
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
errorData: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function reducer(state = defaultState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case DELETE_SHORT_URL_START:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
case DELETE_SHORT_URL_ERROR:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: true,
|
||||||
|
errorData: action.errorData,
|
||||||
|
};
|
||||||
|
case DELETE_SHORT_URL:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
shortCode: action.shortCode,
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
case RESET_DELETE_SHORT_URL:
|
||||||
|
return defaultState;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
|
||||||
|
dispatch({ type: DELETE_SHORT_URL_START });
|
||||||
|
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
const { deleteShortUrl } = buildShlinkApiClient(selectedServer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteShortUrl(shortCode);
|
||||||
|
dispatch({ type: DELETE_SHORT_URL, shortCode });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL });
|
||||||
|
|
||||||
|
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { curry } from 'ramda';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
import { pick } from 'ramda';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
|
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
|
||||||
@@ -40,8 +39,7 @@ export default function reducer(state = defaultState, action) {
|
|||||||
};
|
};
|
||||||
case EDIT_SHORT_URL_TAGS:
|
case EDIT_SHORT_URL_TAGS:
|
||||||
return {
|
return {
|
||||||
shortCode: action.shortCode,
|
...pick([ 'shortCode', 'tags' ], action),
|
||||||
tags: action.tags,
|
|
||||||
saving: false,
|
saving: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
@@ -52,13 +50,15 @@ export default function reducer(state = defaultState, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (dispatch) => {
|
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
|
||||||
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
|
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
const shlinkApiClient = buildShlinkApiClient(selectedServer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update short URL tags
|
const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags);
|
||||||
await shlinkApiClient.updateShortUrlTags(shortCode, tags);
|
|
||||||
dispatch({ tags, shortCode, type: EDIT_SHORT_URL_TAGS });
|
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
|
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
|
||||||
|
|
||||||
@@ -66,8 +66,6 @@ export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (di
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editShortUrlTags = curry(_editShortUrlTags)(shlinkApiClient);
|
|
||||||
|
|
||||||
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
|
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
|
||||||
|
|
||||||
export const shortUrlTagsEdited = (shortCode, tags) => ({
|
export const shortUrlTagsEdited = (shortCode, tags) => ({
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { curry } from 'ramda';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
|
||||||
import { shortUrlType } from './shortUrlsList';
|
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
|
||||||
const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
|
|
||||||
const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
|
|
||||||
const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
|
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
|
||||||
|
|
||||||
export const shortUrlVisitsType = PropTypes.shape({
|
|
||||||
shortUrl: shortUrlType,
|
|
||||||
visits: PropTypes.array,
|
|
||||||
loading: PropTypes.bool,
|
|
||||||
error: PropTypes.bool,
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
shortUrl: {},
|
|
||||||
visits: [],
|
|
||||||
loading: false,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function dispatch(state = initialState, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case GET_SHORT_URL_VISITS_START:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
loading: true,
|
|
||||||
};
|
|
||||||
case GET_SHORT_URL_VISITS_ERROR:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
error: true,
|
|
||||||
};
|
|
||||||
case GET_SHORT_URL_VISITS:
|
|
||||||
return {
|
|
||||||
shortUrl: action.shortUrl,
|
|
||||||
visits: action.visits,
|
|
||||||
loading: false,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => (dispatch) => {
|
|
||||||
dispatch({ type: GET_SHORT_URL_VISITS_START });
|
|
||||||
|
|
||||||
Promise.all([
|
|
||||||
shlinkApiClient.getShortUrlVisits(shortCode, dates),
|
|
||||||
shlinkApiClient.getShortUrl(shortCode),
|
|
||||||
])
|
|
||||||
.then(([ visits, shortUrl ]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS }))
|
|
||||||
.catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR }));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getShortUrlVisits = curry(_getShortUrlVisits)(shlinkApiClient);
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { assoc, assocPath } from 'ramda';
|
import { assoc, assocPath, propEq, reject } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
|
||||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||||
|
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
||||||
const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
|
export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
|
||||||
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export const shortUrlType = PropTypes.shape({
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
shortUrls: {},
|
shortUrls: {},
|
||||||
loading: true,
|
loading: true,
|
||||||
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function reducer(state = initialState, action) {
|
export default function reducer(state = initialState, action) {
|
||||||
@@ -34,7 +35,7 @@ export default function reducer(state = initialState, action) {
|
|||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: true,
|
error: true,
|
||||||
shortUrls: [],
|
shortUrls: {},
|
||||||
};
|
};
|
||||||
case SHORT_URL_TAGS_EDITED:
|
case SHORT_URL_TAGS_EDITED:
|
||||||
const { data } = state.shortUrls;
|
const { data } = state.shortUrls;
|
||||||
@@ -43,21 +44,28 @@ export default function reducer(state = initialState, action) {
|
|||||||
shortUrl.shortCode === action.shortCode
|
shortUrl.shortCode === action.shortCode
|
||||||
? assoc('tags', action.tags, shortUrl)
|
? assoc('tags', action.tags, shortUrl)
|
||||||
: shortUrl), state);
|
: shortUrl), state);
|
||||||
|
case SHORT_URL_DELETED:
|
||||||
|
return assocPath(
|
||||||
|
[ 'shortUrls', 'data' ],
|
||||||
|
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
|
||||||
|
state,
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch) => {
|
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
||||||
dispatch({ type: LIST_SHORT_URLS_START });
|
dispatch({ type: LIST_SHORT_URLS_START });
|
||||||
|
|
||||||
|
const { selectedServer = {} } = getState();
|
||||||
|
const { listShortUrls } = buildShlinkApiClient(selectedServer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shortUrls = await shlinkApiClient.listShortUrls(params);
|
const shortUrls = await listShortUrls(params);
|
||||||
|
|
||||||
dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
|
dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
|
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listShortUrls = (params = {}) => _listShortUrls(shlinkApiClient, params);
|
|
||||||
|
|||||||
71
src/short-urls/services/provideServices.js
Normal file
71
src/short-urls/services/provideServices.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
|
import { assoc } from 'ramda';
|
||||||
|
import ShortUrls from '../ShortUrls';
|
||||||
|
import SearchBar from '../SearchBar';
|
||||||
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
|
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
||||||
|
import CreateShortUrl from '../CreateShortUrl';
|
||||||
|
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
|
||||||
|
import EditTagsModal from '../helpers/EditTagsModal';
|
||||||
|
import { listShortUrls } from '../reducers/shortUrlsList';
|
||||||
|
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
||||||
|
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
|
||||||
|
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
|
||||||
|
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
||||||
|
|
||||||
|
const provideServices = (bottle, connect) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
||||||
|
bottle.decorator('ShortUrls', reduxConnect(
|
||||||
|
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
|
||||||
|
));
|
||||||
|
|
||||||
|
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||||
|
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||||
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
|
[ 'selectedServer', 'shortUrlsListParams' ],
|
||||||
|
[ 'listShortUrls', 'resetShortUrlParams' ]
|
||||||
|
));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
|
||||||
|
|
||||||
|
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector');
|
||||||
|
bottle.decorator(
|
||||||
|
'CreateShortUrl',
|
||||||
|
connect([ 'shortUrlCreationResult' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
|
||||||
|
);
|
||||||
|
|
||||||
|
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||||
|
bottle.decorator('DeleteShortUrlModal', connect(
|
||||||
|
[ 'shortUrlDeletion' ],
|
||||||
|
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
|
||||||
|
));
|
||||||
|
|
||||||
|
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
|
||||||
|
bottle.decorator('EditTagsModal', connect(
|
||||||
|
[ 'shortUrlTags' ],
|
||||||
|
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
|
||||||
|
));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
||||||
|
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
|
||||||
|
|
||||||
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
|
||||||
|
|
||||||
|
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
|
||||||
|
|
||||||
|
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
|
||||||
|
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -5,25 +5,19 @@ import editIcon from '@fortawesome/fontawesome-free-solid/faPencilAlt';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
|
import TagBullet from './helpers/TagBullet';
|
||||||
import './TagCard.scss';
|
import './TagCard.scss';
|
||||||
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
|
|
||||||
import EditTagModal from './helpers/EditTagModal';
|
|
||||||
|
|
||||||
const propTypes = {
|
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
|
||||||
tag: PropTypes.string,
|
static propTypes = {
|
||||||
currentServerId: PropTypes.string,
|
tag: PropTypes.string,
|
||||||
colorGenerator: colorGeneratorType,
|
currentServerId: PropTypes.string,
|
||||||
};
|
};
|
||||||
const defaultProps = {
|
|
||||||
colorGenerator,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class TagCard extends React.Component {
|
|
||||||
state = { isDeleteModalOpen: false, isEditModalOpen: false };
|
state = { isDeleteModalOpen: false, isEditModalOpen: false };
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { tag, colorGenerator, currentServerId } = this.props;
|
const { tag, currentServerId } = this.props;
|
||||||
const toggleDelete = () =>
|
const toggleDelete = () =>
|
||||||
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
|
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
|
||||||
const toggleEdit = () =>
|
const toggleEdit = () =>
|
||||||
@@ -32,43 +26,23 @@ export default class TagCard extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<Card className="tag-card">
|
<Card className="tag-card">
|
||||||
<CardBody className="tag-card__body">
|
<CardBody className="tag-card__body">
|
||||||
<button
|
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
|
||||||
className="btn btn-light btn-sm tag-card__btn tag-card__btn--last"
|
|
||||||
onClick={toggleDelete}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={deleteIcon} />
|
<FontAwesomeIcon icon={deleteIcon} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
|
||||||
className="btn btn-light btn-sm tag-card__btn"
|
|
||||||
onClick={toggleEdit}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
<FontAwesomeIcon icon={editIcon} />
|
||||||
</button>
|
</button>
|
||||||
<h5 className="tag-card__tag-title">
|
<h5 className="tag-card__tag-title">
|
||||||
<div
|
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
||||||
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
|
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
|
||||||
className="tag-card__tag-bullet"
|
|
||||||
/>
|
|
||||||
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>
|
|
||||||
{tag}
|
|
||||||
</Link>
|
|
||||||
</h5>
|
</h5>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
||||||
<DeleteTagConfirmModal
|
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
|
||||||
tag={tag}
|
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
|
||||||
toggle={toggleDelete}
|
|
||||||
isOpen={this.state.isDeleteModalOpen}
|
|
||||||
/>
|
|
||||||
<EditTagModal
|
|
||||||
tag={tag}
|
|
||||||
toggle={toggleEdit}
|
|
||||||
isOpen={this.state.isEditModalOpen}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
TagCard.propTypes = propTypes;
|
export default TagCard;
|
||||||
TagCard.defaultProps = defaultProps;
|
|
||||||
|
|||||||
@@ -16,17 +16,6 @@
|
|||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-card__tag-bullet {
|
|
||||||
$width: 20px;
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
width: $width;
|
|
||||||
height: $width;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: -4px;
|
|
||||||
margin-right: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-card__btn {
|
.tag-card__btn {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { splitEvery } from 'ramda';
|
||||||
import { pick, splitEvery } from 'ramda';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import MuttedMessage from '../utils/MuttedMessage';
|
import MuttedMessage from '../utils/MuttedMessage';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { filterTags, listTags } from './reducers/tagsList';
|
|
||||||
import TagCard from './TagCard';
|
|
||||||
|
|
||||||
const { ceil } = Math;
|
const { ceil } = Math;
|
||||||
const TAGS_GROUP_SIZE = 4;
|
const TAGS_GROUPS_AMOUNT = 4;
|
||||||
const propTypes = {
|
|
||||||
filterTags: PropTypes.func,
|
const TagsList = (TagCard) => class TagsList extends React.Component {
|
||||||
listTags: PropTypes.func,
|
static propTypes = {
|
||||||
tagsList: PropTypes.shape({
|
filterTags: PropTypes.func,
|
||||||
loading: PropTypes.bool,
|
forceListTags: PropTypes.func,
|
||||||
}),
|
tagsList: PropTypes.shape({
|
||||||
match: PropTypes.object,
|
loading: PropTypes.bool,
|
||||||
};
|
error: PropTypes.bool,
|
||||||
|
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
}),
|
||||||
|
match: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
export class TagsListComponent extends React.Component {
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { listTags } = this.props;
|
const { forceListTags } = this.props;
|
||||||
|
|
||||||
listTags();
|
forceListTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContent() {
|
renderContent() {
|
||||||
@@ -46,7 +46,7 @@ export class TagsListComponent extends React.Component {
|
|||||||
return <MuttedMessage>No tags found</MuttedMessage>;
|
return <MuttedMessage>No tags found</MuttedMessage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUP_SIZE), tagsList.filteredTags);
|
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
@@ -70,23 +70,15 @@ export class TagsListComponent extends React.Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shlink-container">
|
<div className="shlink-container">
|
||||||
{!this.props.tagsList.loading && (
|
{!this.props.tagsList.loading &&
|
||||||
<SearchField
|
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
|
||||||
className="mb-3"
|
}
|
||||||
placeholder="Search tags..."
|
|
||||||
onChange={filterTags}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{this.renderContent()}
|
{this.renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
TagsListComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const TagsList = connect(pick([ 'tagsList' ]), { listTags, filterTags })(TagsListComponent);
|
|
||||||
|
|
||||||
export default TagsList;
|
export default TagsList;
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { pick } from 'ramda';
|
import { tagDeleteType } from '../reducers/tagDelete';
|
||||||
import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete';
|
|
||||||
|
|
||||||
const propTypes = {
|
export default class DeleteTagConfirmModal extends React.Component {
|
||||||
tag: PropTypes.string.isRequired,
|
static propTypes = {
|
||||||
toggle: PropTypes.func.isRequired,
|
tag: PropTypes.string.isRequired,
|
||||||
isOpen: PropTypes.bool.isRequired,
|
toggle: PropTypes.func.isRequired,
|
||||||
deleteTag: PropTypes.func,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
tagDelete: tagDeleteType,
|
deleteTag: PropTypes.func,
|
||||||
tagDeleted: PropTypes.func,
|
tagDelete: tagDeleteType,
|
||||||
};
|
tagDeleted: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
export class DeleteTagConfirmModalComponent extends React.Component {
|
|
||||||
doDelete = () => {
|
doDelete = () => {
|
||||||
const { tag, toggle, deleteTag } = this.props;
|
const { tag, toggle, deleteTag } = this.props;
|
||||||
|
|
||||||
@@ -67,12 +65,3 @@ export class DeleteTagConfirmModalComponent extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DeleteTagConfirmModalComponent.propTypes = propTypes;
|
|
||||||
|
|
||||||
const DeleteTagConfirmModal = connect(
|
|
||||||
pick([ 'tagDelete' ]),
|
|
||||||
{ deleteTag, tagDeleted }
|
|
||||||
)(DeleteTagConfirmModalComponent);
|
|
||||||
|
|
||||||
export default DeleteTagConfirmModal;
|
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||||
import { pick } from 'ramda';
|
|
||||||
import { ChromePicker } from 'react-color';
|
import { ChromePicker } from 'react-color';
|
||||||
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
|
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
|
|
||||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
|
||||||
import './EditTagModal.scss';
|
import './EditTagModal.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
|
||||||
tag: PropTypes.string,
|
static propTypes = {
|
||||||
editTag: PropTypes.func,
|
tag: PropTypes.string,
|
||||||
toggle: PropTypes.func,
|
editTag: PropTypes.func,
|
||||||
tagEdited: PropTypes.func,
|
toggle: PropTypes.func,
|
||||||
colorGenerator: colorGeneratorType,
|
tagEdited: PropTypes.func,
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
tagEdit: PropTypes.shape({
|
tagEdit: PropTypes.shape({
|
||||||
error: PropTypes.bool,
|
error: PropTypes.bool,
|
||||||
editing: PropTypes.bool,
|
editing: PropTypes.bool,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const defaultProps = {
|
|
||||||
colorGenerator,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class EditTagModalComponent extends React.Component {
|
|
||||||
saveTag = (e) => {
|
saveTag = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { tag: oldName, editTag, toggle } = this.props;
|
const { tag: oldName, editTag, toggle } = this.props;
|
||||||
@@ -53,12 +45,12 @@ export class EditTagModalComponent extends React.Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
const { colorGenerator, tag } = props;
|
const { tag } = props;
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
showColorPicker: false,
|
showColorPicker: false,
|
||||||
tag,
|
tag,
|
||||||
color: colorGenerator.getColorForKey(tag),
|
color: getColorForKey(tag),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +123,6 @@ export class EditTagModalComponent extends React.Component {
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
EditTagModalComponent.propTypes = propTypes;
|
|
||||||
EditTagModalComponent.defaultProps = defaultProps;
|
|
||||||
|
|
||||||
const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent);
|
|
||||||
|
|
||||||
export default EditTagModal;
|
export default EditTagModal;
|
||||||
|
|||||||
35
src/tags/helpers/Tag.js
Normal file
35
src/tags/helpers/Tag.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './Tag.scss';
|
||||||
|
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
text: PropTypes.string,
|
||||||
|
children: PropTypes.node,
|
||||||
|
clearable: PropTypes.bool,
|
||||||
|
colorGenerator: colorGeneratorType,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
onClose: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tag = ({
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
clearable,
|
||||||
|
colorGenerator,
|
||||||
|
onClick = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}) => (
|
||||||
|
<span
|
||||||
|
className="badge tag"
|
||||||
|
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children || text}
|
||||||
|
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>×</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
Tag.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default Tag;
|
||||||
20
src/tags/helpers/TagBullet.js
Normal file
20
src/tags/helpers/TagBullet.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import * as PropTypes from 'prop-types';
|
||||||
|
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
||||||
|
import './TagBullet.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
tag: PropTypes.string.isRequired,
|
||||||
|
colorGenerator: colorGeneratorType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagBullet = ({ tag, colorGenerator }) => (
|
||||||
|
<div
|
||||||
|
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
|
||||||
|
className="tag-bullet"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
TagBullet.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default TagBullet;
|
||||||
10
src/tags/helpers/TagBullet.scss
Normal file
10
src/tags/helpers/TagBullet.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.tag-bullet {
|
||||||
|
$width: 20px;
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
width: $width;
|
||||||
|
height: $width;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -4px;
|
||||||
|
margin-right: 7px;
|
||||||
|
}
|
||||||
86
src/tags/helpers/TagsSelector.js
Normal file
86
src/tags/helpers/TagsSelector.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TagsInput from 'react-tagsinput';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Autosuggest from 'react-autosuggest';
|
||||||
|
import { identity } from 'ramda';
|
||||||
|
import TagBullet from './TagBullet';
|
||||||
|
import './TagsSelector.scss';
|
||||||
|
|
||||||
|
const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
tagsList: PropTypes.shape({
|
||||||
|
tags: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
static defaultProps = {
|
||||||
|
placeholder: 'Add tags to the URL',
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { listTags } = this.props;
|
||||||
|
|
||||||
|
listTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { tags, onChange, placeholder, tagsList } = this.props;
|
||||||
|
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
|
||||||
|
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
||||||
|
{getTagDisplayValue(tag)}
|
||||||
|
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const renderAutocompleteInput = (data) => {
|
||||||
|
const { addTag, ...otherProps } = data;
|
||||||
|
const handleOnChange = (e, { method }) => {
|
||||||
|
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-extra-parens
|
||||||
|
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
|
||||||
|
const inputLength = inputValue.length;
|
||||||
|
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autosuggest
|
||||||
|
ref={otherProps.ref}
|
||||||
|
suggestions={suggestions}
|
||||||
|
inputProps={{ ...otherProps, onChange: handleOnChange }}
|
||||||
|
highlightFirstSuggestion
|
||||||
|
shouldRenderSuggestions={(value) => value && value.trim().length > 0}
|
||||||
|
getSuggestionValue={(suggestion) => suggestion}
|
||||||
|
renderSuggestion={(suggestion) => (
|
||||||
|
<React.Fragment>
|
||||||
|
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
|
||||||
|
{suggestion}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
onSuggestionSelected={(e, { suggestion }) => {
|
||||||
|
addTag(suggestion);
|
||||||
|
}}
|
||||||
|
onSuggestionsClearRequested={identity}
|
||||||
|
onSuggestionsFetchRequested={identity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagsInput
|
||||||
|
value={tags}
|
||||||
|
inputProps={{ placeholder }}
|
||||||
|
onlyUnique
|
||||||
|
renderTag={renderTag}
|
||||||
|
renderInput={renderAutocompleteInput}
|
||||||
|
|
||||||
|
// FIXME Workaround to be able to add tags on Android
|
||||||
|
addOnBlur
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagsSelector;
|
||||||
16
src/tags/helpers/TagsSelector.scss
Normal file
16
src/tags/helpers/TagsSelector.scss
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@import '../../utils/base';
|
||||||
|
|
||||||
|
.react-autosuggest__suggestions-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-autosuggest__suggestion {
|
||||||
|
margin-left: -6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-autosuggest__suggestion--highlighted {
|
||||||
|
background-color: $lightGrey;
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { curry } from 'ramda';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||||
const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
||||||
const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
||||||
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
@@ -41,9 +39,12 @@ export default function reducer(state = defaultState, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => {
|
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
|
||||||
dispatch({ type: DELETE_TAG_START });
|
dispatch({ type: DELETE_TAG_START });
|
||||||
|
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
const shlinkApiClient = buildShlinkApiClient(selectedServer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await shlinkApiClient.deleteTags([ tag ]);
|
await shlinkApiClient.deleteTags([ tag ]);
|
||||||
dispatch({ type: DELETE_TAG });
|
dispatch({ type: DELETE_TAG });
|
||||||
@@ -54,6 +55,4 @@ export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTag = curry(_deleteTag)(shlinkApiClient);
|
|
||||||
|
|
||||||
export const tagDeleted = (tag) => ({ type: TAG_DELETED, tag });
|
export const tagDeleted = (tag) => ({ type: TAG_DELETED, tag });
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { curry, pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
|
||||||
import colorGenerator from '../../utils/ColorGenerator';
|
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||||
const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
||||||
const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
||||||
@@ -42,22 +40,25 @@ export default function reducer(state = defaultState, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) =>
|
export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newName, color) => async (
|
||||||
async (dispatch) => {
|
dispatch,
|
||||||
dispatch({ type: EDIT_TAG_START });
|
getState
|
||||||
|
) => {
|
||||||
|
dispatch({ type: EDIT_TAG_START });
|
||||||
|
|
||||||
try {
|
const { selectedServer } = getState();
|
||||||
await shlinkApiClient.editTag(oldName, newName);
|
const shlinkApiClient = buildShlinkApiClient(selectedServer);
|
||||||
colorGenerator.setColorForKey(newName, color);
|
|
||||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: EDIT_TAG_ERROR });
|
|
||||||
|
|
||||||
throw e;
|
try {
|
||||||
}
|
await shlinkApiClient.editTag(oldName, newName);
|
||||||
};
|
colorGenerator.setColorForKey(newName, color);
|
||||||
|
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: EDIT_TAG_ERROR });
|
||||||
|
|
||||||
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const tagEdited = (oldName, newName, color) => ({
|
export const tagEdited = (oldName, newName, color) => ({
|
||||||
type: TAG_EDITED,
|
type: TAG_EDITED,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { reject } from 'ramda';
|
import { isEmpty, reject } from 'ramda';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
import { buildShlinkApiClientWithAxios as buildShlinkApiClient } from '../../utils/services/ShlinkApiClientBuilder';
|
||||||
import { TAG_DELETED } from './tagDelete';
|
import { TAG_DELETED } from './tagDelete';
|
||||||
import { TAG_EDITED } from './tagEdit';
|
import { TAG_EDITED } from './tagEdit';
|
||||||
|
|
||||||
@@ -59,19 +59,24 @@ export default function reducer(state = defaultState, action) {
|
|||||||
case FILTER_TAGS:
|
case FILTER_TAGS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
filteredTags: state.tags.filter(
|
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(action.searchTerm)),
|
||||||
(tag) => tag.toLowerCase().match(action.searchTerm),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _listTags = (shlinkApiClient) => async (dispatch) => {
|
export const _listTags = (buildShlinkApiClient, force = false) => async (dispatch, getState) => {
|
||||||
|
const { tagsList, selectedServer } = getState();
|
||||||
|
|
||||||
|
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch({ type: LIST_TAGS_START });
|
dispatch({ type: LIST_TAGS_START });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const shlinkApiClient = buildShlinkApiClient(selectedServer);
|
||||||
const tags = await shlinkApiClient.listTags();
|
const tags = await shlinkApiClient.listTags();
|
||||||
|
|
||||||
dispatch({ tags, type: LIST_TAGS });
|
dispatch({ tags, type: LIST_TAGS });
|
||||||
@@ -80,7 +85,9 @@ export const _listTags = (shlinkApiClient) => async (dispatch) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listTags = () => _listTags(shlinkApiClient);
|
export const listTags = () => _listTags(buildShlinkApiClient);
|
||||||
|
|
||||||
|
export const forceListTags = () => _listTags(buildShlinkApiClient, true);
|
||||||
|
|
||||||
export const filterTags = (searchTerm) => ({
|
export const filterTags = (searchTerm) => ({
|
||||||
type: FILTER_TAGS,
|
type: FILTER_TAGS,
|
||||||
|
|||||||
37
src/tags/services/provideServices.js
Normal file
37
src/tags/services/provideServices.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import TagsSelector from '../helpers/TagsSelector';
|
||||||
|
import TagCard from '../TagCard';
|
||||||
|
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
||||||
|
import EditTagModal from '../helpers/EditTagModal';
|
||||||
|
import TagsList from '../TagsList';
|
||||||
|
import { filterTags, forceListTags, listTags } from '../reducers/tagsList';
|
||||||
|
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
||||||
|
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||||
|
|
||||||
|
const provideServices = (bottle, connect) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
|
||||||
|
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
|
||||||
|
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
||||||
|
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
||||||
|
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('filterTags', () => filterTags);
|
||||||
|
bottle.serviceFactory('forceListTags', () => forceListTags);
|
||||||
|
bottle.serviceFactory('listTags', () => listTags);
|
||||||
|
bottle.serviceFactory('tagDeleted', () => tagDeleted);
|
||||||
|
bottle.serviceFactory('tagEdited', () => tagEdited);
|
||||||
|
|
||||||
|
bottle.serviceFactory('deleteTag', deleteTag, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
42
src/utils/DateInput.js
Normal file
42
src/utils/DateInput.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { isNil } from 'ramda';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
|
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
|
||||||
|
import * as PropTypes from 'prop-types';
|
||||||
|
import './DateInput.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
|
isClearable: PropTypes.bool,
|
||||||
|
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
|
||||||
|
ref: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DateInput = (props) => {
|
||||||
|
const { className, isClearable, selected, ref = React.createRef() } = props;
|
||||||
|
const showCalendarIcon = !isClearable || isNil(selected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="date-input-container">
|
||||||
|
<DatePicker
|
||||||
|
{...props}
|
||||||
|
className={`date-input-container__input form-control ${className || ''}`}
|
||||||
|
dateFormat="YYYY-MM-DD"
|
||||||
|
readOnly
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
{showCalendarIcon && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={calendarIcon}
|
||||||
|
className="date-input-container__icon"
|
||||||
|
onClick={() => ref.current.input.focus()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DateInput.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default DateInput;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
@import '../utils/mixins/vertical-align';
|
@import './mixins/vertical-align';
|
||||||
@import '../utils/base';
|
@import './base';
|
||||||
|
|
||||||
.date-input-container {
|
.date-input-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -11,7 +11,7 @@ export default function ExternalLink(props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
|
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
|
||||||
{children}
|
{children || href}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ import classnames from 'classnames';
|
|||||||
import './SearchField.scss';
|
import './SearchField.scss';
|
||||||
|
|
||||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||||
const propTypes = {
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
className: PropTypes.string,
|
|
||||||
placeholder: PropTypes.string,
|
|
||||||
};
|
|
||||||
const defaultProps = {
|
|
||||||
className: '',
|
|
||||||
placeholder: 'Search...',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class SearchField extends React.Component {
|
export default class SearchField extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
className: PropTypes.string,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
};
|
||||||
|
static defaultProps = {
|
||||||
|
className: '',
|
||||||
|
placeholder: 'Search...',
|
||||||
|
};
|
||||||
|
|
||||||
state = { showClearBtn: false, searchTerm: '' };
|
state = { showClearBtn: false, searchTerm: '' };
|
||||||
timer = null;
|
timer = null;
|
||||||
|
|
||||||
@@ -64,6 +65,3 @@ export default class SearchField extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchField.propTypes = propTypes;
|
|
||||||
SearchField.defaultProps = defaultProps;
|
|
||||||
|
|||||||
68
src/utils/SortingDropdown.js
Normal file
68
src/utils/SortingDropdown.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
|
||||||
|
import { toPairs } from 'ramda';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
|
import sortAscIcon from '@fortawesome/fontawesome-free-solid/faSortAmountUp';
|
||||||
|
import sortDescIcon from '@fortawesome/fontawesome-free-solid/faSortAmountDown';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { determineOrderDir } from '../utils/utils';
|
||||||
|
import './SortingDropdown.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
items: PropTypes.object.isRequired,
|
||||||
|
orderField: PropTypes.string,
|
||||||
|
orderDir: PropTypes.oneOf([ 'ASC', 'DESC' ]),
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
isButton: PropTypes.bool,
|
||||||
|
right: PropTypes.bool,
|
||||||
|
};
|
||||||
|
const defaultProps = {
|
||||||
|
isButton: true,
|
||||||
|
right: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, right }) => {
|
||||||
|
const handleItemClick = (fieldKey) => () => {
|
||||||
|
const newOrderDir = determineOrderDir(fieldKey, orderField, orderDir);
|
||||||
|
|
||||||
|
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UncontrolledDropdown>
|
||||||
|
<DropdownToggle
|
||||||
|
caret
|
||||||
|
color={isButton ? 'secondary' : 'link'}
|
||||||
|
className={classNames({ 'btn-block': isButton, 'btn-sm sorting-dropdown__paddingless': !isButton })}
|
||||||
|
>
|
||||||
|
Order by
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu
|
||||||
|
right={right}
|
||||||
|
className={classNames('sorting-dropdown__menu', { 'sorting-dropdown__menu--link': !isButton })}
|
||||||
|
>
|
||||||
|
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
||||||
|
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey)}>
|
||||||
|
{fieldValue}
|
||||||
|
{orderField === fieldKey && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={orderDir === 'ASC' ? sortAscIcon : sortDescIcon}
|
||||||
|
className="sorting-dropdown__sort-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem disabled={!orderField} onClick={() => onChange()}>
|
||||||
|
<i>Clear selection</i>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</UncontrolledDropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SortingDropdown.propTypes = propTypes;
|
||||||
|
SortingDropdown.defaultProps = defaultProps;
|
||||||
|
|
||||||
|
export default SortingDropdown;
|
||||||
16
src/utils/SortingDropdown.scss
Normal file
16
src/utils/SortingDropdown.scss
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.sorting-dropdown__menu {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sorting-dropdown__menu--link.sorting-dropdown__menu--link {
|
||||||
|
min-width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sorting-dropdown__sort-icon {
|
||||||
|
margin: 3.5px 0 0;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sorting-dropdown__paddingless.sorting-dropdown__paddingless {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
|
|
||||||
import './Tag.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
colorGenerator: colorGeneratorType,
|
|
||||||
text: PropTypes.string,
|
|
||||||
children: PropTypes.node,
|
|
||||||
clearable: PropTypes.bool,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
onClose: PropTypes.func,
|
|
||||||
};
|
|
||||||
const defaultProps = {
|
|
||||||
colorGenerator,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Tag(
|
|
||||||
{
|
|
||||||
colorGenerator,
|
|
||||||
text,
|
|
||||||
children,
|
|
||||||
clearable,
|
|
||||||
onClick = () => ({}),
|
|
||||||
onClose = () => ({}),
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="badge tag"
|
|
||||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children || text}
|
|
||||||
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>×</span>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Tag.defaultProps = defaultProps;
|
|
||||||
Tag.propTypes = propTypes;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import TagsInput from 'react-tagsinput';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import colorGenerator, { colorGeneratorType } from './ColorGenerator';
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
colorGenerator,
|
|
||||||
placeholder: 'Add tags to the URL',
|
|
||||||
};
|
|
||||||
const propTypes = {
|
|
||||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
placeholder: PropTypes.string,
|
|
||||||
colorGenerator: colorGeneratorType,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TagsSelector({ tags, onChange, placeholder, colorGenerator }) {
|
|
||||||
const renderTag = (props) => {
|
|
||||||
const { tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
|
||||||
{getTagDisplayValue(tag)}
|
|
||||||
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TagsInput
|
|
||||||
value={tags}
|
|
||||||
inputProps={{ placeholder }}
|
|
||||||
onlyUnique
|
|
||||||
renderTag={renderTag}
|
|
||||||
|
|
||||||
// FIXME Workaround to be able to add tags on Android
|
|
||||||
addOnBlur
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TagsSelector.defaultProps = defaultProps;
|
|
||||||
TagsSelector.propTypes = propTypes;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { range } from 'ramda';
|
import { range } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import storage from './Storage';
|
|
||||||
|
|
||||||
const HEX_COLOR_LENGTH = 6;
|
const HEX_COLOR_LENGTH = 6;
|
||||||
const { floor, random } = Math;
|
const { floor, random } = Math;
|
||||||
@@ -11,29 +10,33 @@ const buildRandomColor = () =>
|
|||||||
.map(() => letters[floor(random() * letters.length)])
|
.map(() => letters[floor(random() * letters.length)])
|
||||||
.join('')
|
.join('')
|
||||||
}`;
|
}`;
|
||||||
|
const normalizeKey = (key) => key.toLowerCase().trim();
|
||||||
|
|
||||||
export class ColorGenerator {
|
export default class ColorGenerator {
|
||||||
constructor(storage) {
|
constructor(storage) {
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.colors = this.storage.get('colors') || {};
|
this.colors = this.storage.get('colors') || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
getColorForKey = (key) => {
|
getColorForKey = (key) => {
|
||||||
const color = this.colors[key];
|
const normalizedKey = normalizeKey(key);
|
||||||
|
const color = this.colors[normalizedKey];
|
||||||
|
|
||||||
// If a color has not been set yet, generate a random one and save it
|
// If a color has not been set yet, generate a random one and save it
|
||||||
if (!color) {
|
if (!color) {
|
||||||
this.setColorForKey(key, buildRandomColor());
|
return this.setColorForKey(normalizedKey, buildRandomColor());
|
||||||
|
|
||||||
return this.getColorForKey(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return color;
|
return color;
|
||||||
};
|
};
|
||||||
|
|
||||||
setColorForKey = (key, color) => {
|
setColorForKey = (key, color) => {
|
||||||
this.colors[key] = color;
|
const normalizedKey = normalizeKey(key);
|
||||||
|
|
||||||
|
this.colors[normalizedKey] = color;
|
||||||
this.storage.set('colors', this.colors);
|
this.storage.set('colors', this.colors);
|
||||||
|
|
||||||
|
return color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +44,3 @@ export const colorGeneratorType = PropTypes.shape({
|
|||||||
getColorForKey: PropTypes.func,
|
getColorForKey: PropTypes.func,
|
||||||
setColorForKey: PropTypes.func,
|
setColorForKey: PropTypes.func,
|
||||||
});
|
});
|
||||||
|
|
||||||
const colorGenerator = new ColorGenerator(storage);
|
|
||||||
|
|
||||||
export default colorGenerator;
|
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
import { isEmpty, isNil, reject } from 'ramda';
|
||||||
|
|
||||||
const API_VERSION = '1';
|
const API_VERSION = '1';
|
||||||
const STATUS_UNAUTHORIZED = 401;
|
const STATUS_UNAUTHORIZED = 401;
|
||||||
|
const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
|
||||||
|
|
||||||
export class ShlinkApiClient {
|
export default class ShlinkApiClient {
|
||||||
constructor(axios) {
|
constructor(axios, baseUrl, apiKey) {
|
||||||
this.axios = axios;
|
this.axios = axios;
|
||||||
this._baseUrl = '';
|
this._baseUrl = buildRestUrl(baseUrl);
|
||||||
this._apiKey = '';
|
this._apiKey = apiKey || '';
|
||||||
this._token = '';
|
this._token = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the base URL to be used on any request
|
|
||||||
*/
|
|
||||||
setConfig = ({ url, apiKey }) => {
|
|
||||||
this._baseUrl = `${url}/rest/v${API_VERSION}`;
|
|
||||||
this._apiKey = apiKey;
|
|
||||||
};
|
|
||||||
|
|
||||||
listShortUrls = (options = {}) =>
|
listShortUrls = (options = {}) =>
|
||||||
this._performRequest('/short-codes', 'GET', options)
|
this._performRequest('/short-codes', 'GET', options)
|
||||||
.then((resp) => resp.data.shortUrls)
|
.then((resp) => resp.data.shortUrls)
|
||||||
@@ -44,6 +36,11 @@ export class ShlinkApiClient {
|
|||||||
.then((resp) => resp.data)
|
.then((resp) => resp.data)
|
||||||
.catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ]));
|
.catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ]));
|
||||||
|
|
||||||
|
deleteShortUrl = (shortCode) =>
|
||||||
|
this._performRequest(`/short-codes/${shortCode}`, 'DELETE')
|
||||||
|
.then(() => ({}))
|
||||||
|
.catch((e) => this._handleAuthError(e, this.deleteShortUrl, [ shortCode ]));
|
||||||
|
|
||||||
updateShortUrlTags = (shortCode, tags) =>
|
updateShortUrlTags = (shortCode, tags) =>
|
||||||
this._performRequest(`/short-codes/${shortCode}/tags`, 'PUT', {}, { tags })
|
this._performRequest(`/short-codes/${shortCode}/tags`, 'PUT', {}, { tags })
|
||||||
.then((resp) => resp.data.tags)
|
.then((resp) => resp.data.tags)
|
||||||
@@ -108,7 +105,3 @@ export class ShlinkApiClient {
|
|||||||
return Promise.reject(e);
|
return Promise.reject(e);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const shlinkApiClient = new ShlinkApiClient(axios);
|
|
||||||
|
|
||||||
export default shlinkApiClient;
|
|
||||||
18
src/utils/services/ShlinkApiClientBuilder.js
Normal file
18
src/utils/services/ShlinkApiClientBuilder.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as axios from 'axios';
|
||||||
|
import ShlinkApiClient from './ShlinkApiClient';
|
||||||
|
|
||||||
|
const apiClients = {};
|
||||||
|
|
||||||
|
const buildShlinkApiClient = (axios) => ({ url, apiKey }) => {
|
||||||
|
const clientKey = `${url}_${apiKey}`;
|
||||||
|
|
||||||
|
if (!apiClients[clientKey]) {
|
||||||
|
apiClients[clientKey] = new ShlinkApiClient(axios, url, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClients[clientKey];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildShlinkApiClient;
|
||||||
|
|
||||||
|
export const buildShlinkApiClientWithAxios = buildShlinkApiClient(axios);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const PREFIX = 'shlink';
|
const PREFIX = 'shlink';
|
||||||
const buildPath = (path) => `${PREFIX}.${path}`;
|
const buildPath = (path) => `${PREFIX}.${path}`;
|
||||||
|
|
||||||
export class Storage {
|
export default class Storage {
|
||||||
constructor(localStorage) {
|
constructor(localStorage) {
|
||||||
this.localStorage = localStorage;
|
this.localStorage = localStorage;
|
||||||
}
|
}
|
||||||
@@ -14,15 +14,3 @@ export class Storage {
|
|||||||
|
|
||||||
set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
|
set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserStorage = global.localStorage || {
|
|
||||||
getItem() {
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
setItem() {
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const storage = new Storage(browserStorage);
|
|
||||||
|
|
||||||
export default storage;
|
|
||||||
15
src/utils/services/provideServices.js
Normal file
15
src/utils/services/provideServices.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import Storage from './Storage';
|
||||||
|
import ColorGenerator from './ColorGenerator';
|
||||||
|
import buildShlinkApiClient from './ShlinkApiClientBuilder';
|
||||||
|
|
||||||
|
const provideServices = (bottle) => {
|
||||||
|
bottle.constant('localStorage', global.localStorage);
|
||||||
|
bottle.service('Storage', Storage, 'localStorage');
|
||||||
|
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
|
||||||
|
|
||||||
|
bottle.constant('axios', axios);
|
||||||
|
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -4,3 +4,16 @@ export const stateFlagTimeout = (setState, flagName, initialValue = true, delay
|
|||||||
setState({ [flagName]: initialValue });
|
setState({ [flagName]: initialValue });
|
||||||
setTimeout(() => setState({ [flagName]: !initialValue }), delay);
|
setTimeout(() => setState({ [flagName]: !initialValue }), delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const determineOrderDir = (clickedField, currentOrderField, currentOrderDir) => {
|
||||||
|
if (currentOrderField !== clickedField) {
|
||||||
|
return 'ASC';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrderMap = {
|
||||||
|
ASC: 'DESC',
|
||||||
|
DESC: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
||||||
|
};
|
||||||
|
|||||||
97
src/visits/GraphCard.js
Normal file
97
src/visits/GraphCard.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Card, CardHeader, CardBody } from 'reactstrap';
|
||||||
|
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import { keys, values } from 'ramda';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
title: PropTypes.string,
|
||||||
|
children: PropTypes.node,
|
||||||
|
isBarChart: PropTypes.bool,
|
||||||
|
stats: PropTypes.object,
|
||||||
|
matchMedia: PropTypes.func,
|
||||||
|
};
|
||||||
|
const defaultProps = {
|
||||||
|
matchMedia: global.window ? global.window.matchMedia : () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateGraphData = (title, isBarChart, labels, data) => ({
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
||||||
|
'#97BBCD',
|
||||||
|
'#DCDCDC',
|
||||||
|
'#F7464A',
|
||||||
|
'#46BFBD',
|
||||||
|
'#FDB45C',
|
||||||
|
'#949FB1',
|
||||||
|
'#4D5360',
|
||||||
|
],
|
||||||
|
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const determineGraphAspectRatio = (barsCount, isBarChart, matchMedia) => {
|
||||||
|
const determineAspectRationModifier = () => {
|
||||||
|
switch (true) {
|
||||||
|
case matchMedia('(max-width: 1200px)').matches:
|
||||||
|
return 1.5; // eslint-disable-line no-magic-numbers
|
||||||
|
case matchMedia('(max-width: 992px)').matches:
|
||||||
|
return 1.75; // eslint-disable-line no-magic-numbers
|
||||||
|
case matchMedia('(max-width: 768px)').matches:
|
||||||
|
return 2; // eslint-disable-line no-magic-numbers
|
||||||
|
case matchMedia('(max-width: 576px)').matches:
|
||||||
|
return 2.25; // eslint-disable-line no-magic-numbers
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_BARS_WITHOUT_HEIGHT = 20;
|
||||||
|
const DEFAULT_ASPECT_RATION = 2;
|
||||||
|
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
|
||||||
|
|
||||||
|
return shouldCalculateAspectRatio
|
||||||
|
? MAX_BARS_WITHOUT_HEIGHT / determineAspectRationModifier() * DEFAULT_ASPECT_RATION / barsCount
|
||||||
|
: DEFAULT_ASPECT_RATION;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGraph = (title, isBarChart, stats, matchMedia) => {
|
||||||
|
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||||
|
const labels = keys(stats);
|
||||||
|
const data = values(stats);
|
||||||
|
const aspectRatio = determineGraphAspectRatio(labels.length, isBarChart, matchMedia);
|
||||||
|
const options = {
|
||||||
|
aspectRatio,
|
||||||
|
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||||
|
scales: isBarChart ? {
|
||||||
|
xAxes: [
|
||||||
|
{
|
||||||
|
ticks: { beginAtZero: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} : null,
|
||||||
|
tooltips: {
|
||||||
|
intersect: !isBarChart,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={null} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GraphCard = ({ title, children, isBarChart, stats, matchMedia }) => (
|
||||||
|
<Card className="mt-4">
|
||||||
|
<CardHeader className="graph-card__header">{children || title}</CardHeader>
|
||||||
|
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
GraphCard.propTypes = propTypes;
|
||||||
|
GraphCard.defaultProps = defaultProps;
|
||||||
|
|
||||||
|
export default GraphCard;
|
||||||
139
src/visits/ShortUrlVisits.js
Normal file
139
src/visits/ShortUrlVisits.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
|
||||||
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
|
import { isEmpty, mapObjIndexed } from 'ramda';
|
||||||
|
import React from 'react';
|
||||||
|
import { Card } from 'reactstrap';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import DateInput from '../utils/DateInput';
|
||||||
|
import MutedMessage from '../utils/MuttedMessage';
|
||||||
|
import SortableBarGraph from './SortableBarGraph';
|
||||||
|
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||||
|
import { VisitsHeader } from './VisitsHeader';
|
||||||
|
import GraphCard from './GraphCard';
|
||||||
|
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||||
|
import './ShortUrlVisits.scss';
|
||||||
|
|
||||||
|
const ShortUrlVisits = ({
|
||||||
|
processOsStats,
|
||||||
|
processBrowserStats,
|
||||||
|
processCountriesStats,
|
||||||
|
processReferrersStats,
|
||||||
|
}) => class ShortUrlVisits extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
match: PropTypes.shape({
|
||||||
|
params: PropTypes.object,
|
||||||
|
}),
|
||||||
|
getShortUrlVisits: PropTypes.func,
|
||||||
|
shortUrlVisits: shortUrlVisitsType,
|
||||||
|
getShortUrlDetail: PropTypes.func,
|
||||||
|
shortUrlDetail: shortUrlDetailType,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = { startDate: undefined, endDate: undefined };
|
||||||
|
loadVisits = () => {
|
||||||
|
const { match: { params }, getShortUrlVisits } = this.props;
|
||||||
|
|
||||||
|
getShortUrlVisits(params.shortCode, mapObjIndexed(
|
||||||
|
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
|
||||||
|
this.state
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { match: { params }, getShortUrlDetail } = this.props;
|
||||||
|
|
||||||
|
this.loadVisits();
|
||||||
|
getShortUrlDetail(params.shortCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { shortUrlVisits, shortUrlDetail } = this.props;
|
||||||
|
|
||||||
|
const renderVisitsContent = () => {
|
||||||
|
const { visits, loading, error } = shortUrlVisits;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="mt-4" body inverse color="danger">
|
||||||
|
An error occurred while loading visits :(
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(visits)) {
|
||||||
|
return <MutedMessage>There are no visits matching current filter :(</MutedMessage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<GraphCard title="Operating systems" stats={processOsStats(visits)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<GraphCard title="Browsers" stats={processBrowserStats(visits)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<SortableBarGraph
|
||||||
|
stats={processCountriesStats(visits)}
|
||||||
|
title="Countries"
|
||||||
|
sortingItems={{
|
||||||
|
name: 'Country name',
|
||||||
|
amount: 'Visits amount',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<SortableBarGraph
|
||||||
|
stats={processReferrersStats(visits)}
|
||||||
|
title="Referrers"
|
||||||
|
sortingItems={{
|
||||||
|
name: 'Referrer name',
|
||||||
|
amount: 'Visits amount',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shlink-container">
|
||||||
|
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
|
||||||
|
|
||||||
|
<section className="mt-4">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
||||||
|
<DateInput
|
||||||
|
selected={this.state.startDate}
|
||||||
|
placeholderText="Since"
|
||||||
|
isClearable
|
||||||
|
maxDate={this.state.endDate}
|
||||||
|
onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||||
|
<DateInput
|
||||||
|
className="short-url-visits__date-input"
|
||||||
|
selected={this.state.endDate}
|
||||||
|
placeholderText="Until"
|
||||||
|
isClearable
|
||||||
|
minDate={this.state.startDate}
|
||||||
|
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{renderVisitsContent()}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortUrlVisits;
|
||||||
54
src/visits/SortableBarGraph.js
Normal file
54
src/visits/SortableBarGraph.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { fromPairs, head, identity, keys, pipe, prop, reverse, sortBy, toLower, toPairs, type } from 'ramda';
|
||||||
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
|
import GraphCard from './GraphCard';
|
||||||
|
|
||||||
|
export default class SortableBarGraph extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
stats: PropTypes.object.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
sortingItems: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
orderField: undefined,
|
||||||
|
orderDir: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { stats, sortingItems, title } = this.props;
|
||||||
|
const sortStats = () => {
|
||||||
|
if (!this.state.orderField) {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : identity(value);
|
||||||
|
const sortedPairs = sortBy(
|
||||||
|
pipe(
|
||||||
|
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
|
||||||
|
toLowerIfString
|
||||||
|
),
|
||||||
|
toPairs(stats)
|
||||||
|
);
|
||||||
|
|
||||||
|
return fromPairs(this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GraphCard stats={sortStats()} isBarChart>
|
||||||
|
{title}
|
||||||
|
<div className="float-right">
|
||||||
|
<SortingDropdown
|
||||||
|
isButton={false}
|
||||||
|
right
|
||||||
|
orderField={this.state.orderField}
|
||||||
|
orderDir={this.state.orderDir}
|
||||||
|
items={sortingItems}
|
||||||
|
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</GraphCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/visits/VisitsHeader.js
Normal file
55
src/visits/VisitsHeader.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Card, UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import Moment from 'react-moment';
|
||||||
|
import React from 'react';
|
||||||
|
import ExternalLink from '../utils/ExternalLink';
|
||||||
|
import './VisitsHeader.scss';
|
||||||
|
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||||
|
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
shortUrlDetail: shortUrlDetailType.isRequired,
|
||||||
|
shortUrlVisits: shortUrlVisitsType.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
|
||||||
|
const { shortUrl, loading } = shortUrlDetail;
|
||||||
|
const { visits } = shortUrlVisits;
|
||||||
|
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
||||||
|
const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : '';
|
||||||
|
|
||||||
|
const renderDate = () => (
|
||||||
|
<span>
|
||||||
|
<b id="created" className="visits-header__created-at"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
|
||||||
|
<UncontrolledTooltip placement="bottom" target="created">
|
||||||
|
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<Card className="bg-light" body>
|
||||||
|
<h2>
|
||||||
|
<span className="badge badge-main float-right">Visits: {visits.length}</span>
|
||||||
|
Visit stats for <ExternalLink href={shortLink} />
|
||||||
|
</h2>
|
||||||
|
<hr />
|
||||||
|
{shortUrl.dateCreated && (
|
||||||
|
<div>
|
||||||
|
Created:
|
||||||
|
|
||||||
|
{renderDate()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
Long URL:
|
||||||
|
|
||||||
|
{loading && <small>Loading...</small>}
|
||||||
|
{!loading && <ExternalLink href={longLink} />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VisitsHeader.propTypes = propTypes;
|
||||||
3
src/visits/VisitsHeader.scss
Normal file
3
src/visits/VisitsHeader.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.visits-header__created-at {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
59
src/visits/reducers/shortUrlDetail.js
Normal file
59
src/visits/reducers/shortUrlDetail.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { shortUrlType } from '../../short-urls/reducers/shortUrlsList';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
|
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
||||||
|
export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR';
|
||||||
|
export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL';
|
||||||
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
|
export const shortUrlDetailType = PropTypes.shape({
|
||||||
|
shortUrl: shortUrlType,
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
error: PropTypes.bool,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
shortUrl: {},
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function reducer(state = initialState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case GET_SHORT_URL_DETAIL_START:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
case GET_SHORT_URL_DETAIL_ERROR:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: true,
|
||||||
|
};
|
||||||
|
case GET_SHORT_URL_DETAIL:
|
||||||
|
return {
|
||||||
|
shortUrl: action.shortUrl,
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
|
||||||
|
dispatch({ type: GET_SHORT_URL_DETAIL_START });
|
||||||
|
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
const shlinkApiClient = buildShlinkApiClient(selectedServer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shortUrl = await shlinkApiClient.getShortUrl(shortCode);
|
||||||
|
|
||||||
|
dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: GET_SHORT_URL_DETAIL_ERROR });
|
||||||
|
}
|
||||||
|
};
|
||||||
58
src/visits/reducers/shortUrlVisits.js
Normal file
58
src/visits/reducers/shortUrlVisits.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
|
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
|
||||||
|
export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
|
||||||
|
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
|
||||||
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
|
export const shortUrlVisitsType = PropTypes.shape({
|
||||||
|
visits: PropTypes.array,
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
error: PropTypes.bool,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
visits: [],
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function reducer(state = initialState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case GET_SHORT_URL_VISITS_START:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
case GET_SHORT_URL_VISITS_ERROR:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: true,
|
||||||
|
};
|
||||||
|
case GET_SHORT_URL_VISITS:
|
||||||
|
return {
|
||||||
|
visits: action.visits,
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => async (dispatch, getState) => {
|
||||||
|
dispatch({ type: GET_SHORT_URL_VISITS_START });
|
||||||
|
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
const shlinkApiClient = buildShlinkApiClient(selectedServer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const visits = await shlinkApiClient.getShortUrlVisits(shortCode, dates);
|
||||||
|
|
||||||
|
dispatch({ visits, type: GET_SHORT_URL_VISITS });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
|
||||||
|
}
|
||||||
|
};
|
||||||
22
src/visits/services/provideServices.js
Normal file
22
src/visits/services/provideServices.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import ShortUrlVisits from '../ShortUrlVisits';
|
||||||
|
import { getShortUrlVisits } from '../reducers/shortUrlVisits';
|
||||||
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
|
import * as visitsParser from './VisitsParser';
|
||||||
|
|
||||||
|
const provideServices = (bottle, connect) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser');
|
||||||
|
bottle.decorator('ShortUrlVisits', connect(
|
||||||
|
[ 'shortUrlVisits', 'shortUrlDetail' ],
|
||||||
|
[ 'getShortUrlVisits', 'getShortUrlDetail' ]
|
||||||
|
));
|
||||||
|
|
||||||
|
// Services
|
||||||
|
bottle.serviceFactory('VisitsParser', () => visitsParser);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { Route } from 'react-router-dom';
|
import { Route } from 'react-router-dom';
|
||||||
import App from '../src/App';
|
import { identity } from 'ramda';
|
||||||
import MainHeader from '../src/common/MainHeader';
|
import appFactory from '../src/App';
|
||||||
|
|
||||||
describe('<App />', () => {
|
describe('<App />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
const MainHeader = () => '';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
const App = appFactory(MainHeader, identity, identity, identity);
|
||||||
|
|
||||||
wrapper = shallow(<App />);
|
wrapper = shallow(<App />);
|
||||||
});
|
});
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import AsideMenu from '../../src/common/AsideMenu';
|
import asideMenuCreator from '../../src/common/AsideMenu';
|
||||||
|
|
||||||
describe('<AsideMenu />', () => {
|
describe('<AsideMenu />', () => {
|
||||||
let wrapped;
|
let wrapped;
|
||||||
|
const DeleteServerButton = () => '';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
const AsideMenu = asideMenuCreator(DeleteServerButton);
|
||||||
|
|
||||||
wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />);
|
wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => wrapped.unmount());
|
||||||
wrapped.unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('contains links to different sections', () => {
|
it('contains links to different sections', () => {
|
||||||
const links = wrapped.find(NavLink);
|
const links = wrapped.find(NavLink);
|
||||||
@@ -22,6 +23,6 @@ describe('<AsideMenu />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('contains a button to delete server', () => {
|
it('contains a button to delete server', () => {
|
||||||
expect(wrapped.find('DeleteServerButton')).toHaveLength(1);
|
expect(wrapped.find(DeleteServerButton)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { shallow } from 'enzyme';
|
|||||||
import { values } from 'ramda';
|
import { values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as sinon from 'sinon';
|
import * as sinon from 'sinon';
|
||||||
import { HomeComponent } from '../../src/common/Home';
|
import Home from '../../src/common/Home';
|
||||||
|
|
||||||
describe('<Home />', () => {
|
describe('<Home />', () => {
|
||||||
let wrapped;
|
let wrapped;
|
||||||
@@ -15,7 +15,7 @@ describe('<Home />', () => {
|
|||||||
const createComponent = (props) => {
|
const createComponent = (props) => {
|
||||||
const actualProps = { ...defaultProps, ...props };
|
const actualProps = { ...defaultProps, ...props };
|
||||||
|
|
||||||
wrapped = shallow(<HomeComponent {...actualProps} />);
|
wrapped = shallow(<Home {...actualProps} />);
|
||||||
|
|
||||||
return wrapped;
|
return wrapped;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import React from 'react';
|
|||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { identity } from 'ramda';
|
import { identity } from 'ramda';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { CreateServerComponent } from '../../src/servers/CreateServer';
|
import createServerConstruct from '../../src/servers/CreateServer';
|
||||||
import ImportServersBtn from '../../src/servers/helpers/ImportServersBtn';
|
|
||||||
|
|
||||||
describe('<CreateServer />', () => {
|
describe('<CreateServer />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
const ImportServersBtn = () => '';
|
||||||
const createServerMock = sinon.fake();
|
const createServerMock = sinon.fake();
|
||||||
const historyMock = {
|
const historyMock = {
|
||||||
push: sinon.fake(),
|
push: sinon.fake(),
|
||||||
@@ -16,12 +16,10 @@ describe('<CreateServer />', () => {
|
|||||||
createServerMock.resetHistory();
|
createServerMock.resetHistory();
|
||||||
historyMock.push.resetHistory();
|
historyMock.push.resetHistory();
|
||||||
|
|
||||||
|
const CreateServer = createServerConstruct(ImportServersBtn);
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<CreateServerComponent
|
<CreateServer createServer={createServerMock} resetSelectedServer={identity} history={historyMock} />
|
||||||
createServer={createServerMock}
|
|
||||||
resetSelectedServer={identity}
|
|
||||||
history={historyMock}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import DeleteServerButton from '../../src/servers/DeleteServerButton';
|
import deleteServerButtonConstruct from '../../src/servers/DeleteServerButton';
|
||||||
import DeleteServerModal from '../../src/servers/DeleteServerModal';
|
import DeleteServerModal from '../../src/servers/DeleteServerModal';
|
||||||
|
|
||||||
describe('<DeleteServerButton />', () => {
|
describe('<DeleteServerButton />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
const DeleteServerButton = deleteServerButtonConstruct(DeleteServerModal);
|
||||||
|
|
||||||
wrapper = shallow(<DeleteServerButton server={{}} className="button" />);
|
wrapper = shallow(<DeleteServerButton server={{}} className="button" />);
|
||||||
});
|
});
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { DeleteServerModalComponent } from '../../src/servers/DeleteServerModal';
|
import DeleteServerModal from '../../src/servers/DeleteServerModal';
|
||||||
|
|
||||||
describe('<DeleteServerModal />', () => {
|
describe('<DeleteServerModal />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
@@ -17,7 +17,7 @@ describe('<DeleteServerModal />', () => {
|
|||||||
historyMock.push.resetHistory();
|
historyMock.push.resetHistory();
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<DeleteServerModalComponent
|
<DeleteServerModal
|
||||||
server={{ name: serverName }}
|
server={{ name: serverName }}
|
||||||
toggle={toggleMock}
|
toggle={toggleMock}
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { identity, values } from 'ramda';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { DropdownItem, DropdownToggle } from 'reactstrap';
|
import { DropdownItem, DropdownToggle } from 'reactstrap';
|
||||||
import { ServersDropdownComponent } from '../../src/servers/ServersDropdown';
|
import serversDropdownCreator from '../../src/servers/ServersDropdown';
|
||||||
|
|
||||||
describe('<ServersDropdown />', () => {
|
describe('<ServersDropdown />', () => {
|
||||||
let wrapped;
|
let wrapped;
|
||||||
|
let ServersDropdown;
|
||||||
const servers = {
|
const servers = {
|
||||||
'1a': { name: 'foo', id: 1 },
|
'1a': { name: 'foo', id: 1 },
|
||||||
'2b': { name: 'bar', id: 2 },
|
'2b': { name: 'bar', id: 2 },
|
||||||
@@ -13,7 +14,8 @@ describe('<ServersDropdown />', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapped = shallow(<ServersDropdownComponent servers={servers} listServers={identity} />);
|
ServersDropdown = serversDropdownCreator({});
|
||||||
|
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} />);
|
||||||
});
|
});
|
||||||
afterEach(() => wrapped.unmount());
|
afterEach(() => wrapped.unmount());
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ describe('<ServersDropdown />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('contains a message when no servers exist yet', () => {
|
it('contains a message when no servers exist yet', () => {
|
||||||
wrapped = shallow(<ServersDropdownComponent servers={{}} listServers={identity} />);
|
wrapped = shallow(<ServersDropdown servers={{}} listServers={identity} />);
|
||||||
const item = wrapped.find(DropdownItem);
|
const item = wrapped.find(DropdownItem);
|
||||||
|
|
||||||
expect(item).toHaveLength(1);
|
expect(item).toHaveLength(1);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user