Compare commits

...

77 Commits

Author SHA1 Message Date
Alejandro Celaya
019ce2e8ed Added v1.2.1 to changelog 2018-12-21 11:03:51 +01:00
Alejandro Celaya
e93082a64d Merge pull request #86 from acelaya/feature/code-coverage
Feature/code coverage
2018-12-21 11:03:12 +01:00
Alejandro Celaya
8a9a4f40a7 Created shortUrlsList reducer test 2018-12-21 10:58:51 +01:00
Alejandro Celaya
047d99be6d Updated ScrollTop component so that it gets the window object injected as a dependency 2018-12-21 10:38:40 +01:00
Alejandro Celaya
16cf30f26f Created EditTagsModal component test 2018-12-21 10:34:12 +01:00
Alejandro Celaya
9268114fe1 Created shortUrlDeletion reducer test 2018-12-21 10:02:42 +01:00
Alejandro Celaya
fefbb73568 Created Storage service test 2018-12-19 20:43:55 +01:00
Alejandro Celaya
12cddac27a Merge pull request #83 from acelaya/feature/insensitive-ordering
Ensured bar graphs are sorted case insensitive
2018-12-19 11:15:30 +01:00
Alejandro Celaya
77a2f32cfd Ensured bar graphs are sorted case insensitive 2018-12-19 11:07:47 +01:00
Alejandro Celaya
9d513e9ea0 Merge pull request #82 from acelaya/feature/disable-tag-build
Disabled yarn build when a tag exists in travis
2018-12-19 10:44:48 +01:00
Alejandro Celaya
9df2de5b30 Disabled yarn build when a tag exists in travis 2018-12-19 10:38:00 +01:00
Alejandro Celaya
fd2367b005 Updated nginx base image for Docker. Closes #79 2018-12-18 20:33:37 +01:00
Alejandro Celaya
a2b08277dc Merge pull request #81 from acelaya/feature/dependency-injection
Feature/dependency injection
2018-12-18 20:31:25 +01:00
Alejandro Celaya
8bd3a15a1d Updated changelog 2018-12-18 20:28:21 +01:00
Alejandro Celaya
cd11dd9848 Updated tests config excluding config files form code coverage 2018-12-18 20:24:18 +01:00
Alejandro Celaya
eec79043cc Moved common and utils services to their own service providers 2018-12-18 20:19:22 +01:00
Alejandro Celaya
4b1f5e9f4c Extracted short-url related services to its own service provider 2018-12-18 20:00:23 +01:00
Alejandro Celaya
cf1239cf6e Moved all server-related services to its own service provider 2018-12-18 19:45:09 +01:00
Alejandro Celaya
566322a8c5 Extracted tag related services to its own service provider 2018-12-18 14:55:00 +01:00
Alejandro Celaya
fa3e1eba93 Moved all visits-related services to its own service provide function inside visits 2018-12-18 14:36:32 +01:00
Alejandro Celaya
471322f4db Implemented dependency injection in all tag related components 2018-12-18 11:28:15 +01:00
Alejandro Celaya
79a0a5e4ea Fixed tests 2018-12-18 10:23:09 +01:00
Alejandro Celaya
4f54e3315f Simplified ShlinkApiClient and moved runtime creation logic to external service 2018-12-18 10:14:25 +01:00
Alejandro Celaya
7bd4b39b5a Added lazy loading to action services 2018-12-18 05:03:38 +01:00
Alejandro Celaya
12ddeebedf Registered first actions as services 2018-12-18 04:54:32 +01:00
Alejandro Celaya
d6e53918a2 Created function which dynamically resolve action services from the container for connected components 2018-12-18 04:34:37 +01:00
Alejandro Celaya
bab1e57ab1 Registered remaining short URLs components in DI container 2018-12-17 23:11:55 +01:00
Alejandro Celaya
bec755b121 Fixed tests 2018-12-17 22:32:51 +01:00
Alejandro Celaya
5616d045ab Migrated a lot more components to new DI system 2018-12-17 22:18:47 +01:00
Alejandro Celaya
5e6ad14a85 More components migrated for dependency injection 2018-12-17 20:24:31 +01:00
Alejandro Celaya
79a518b02d Registered first components as services 2018-12-17 20:03:36 +01:00
Alejandro Celaya
e996a08c02 Added v1.2.0 to changelog 2018-11-01 19:31:53 +01:00
Alejandro Celaya
cc206c2843 Added missing changelog entry 2018-11-01 15:03:26 +01:00
Alejandro Celaya
591c3b76f9 Merge pull request #70 from acelaya/feature/tests
Feature/tests
2018-11-01 15:02:09 +01:00
Alejandro Celaya
07b1d5be2e Created shortUrlCreation reducer test 2018-11-01 14:55:30 +01:00
Alejandro Celaya
f94b5b7c68 Created tagDelete reducer test 2018-11-01 14:44:55 +01:00
Alejandro Celaya
824a2facac Created tagEdit reducer test 2018-11-01 14:13:49 +01:00
Alejandro Celaya
4445c79540 Created TagsList test 2018-11-01 13:51:03 +01:00
Alejandro Celaya
85cb849ba5 Created TagCard test 2018-11-01 13:34:31 +01:00
Alejandro Celaya
53132fa900 Created CreateShortUrl test 2018-11-01 13:15:09 +01:00
Alejandro Celaya
c774a00610 Created ShortUrls test 2018-11-01 12:44:27 +01:00
Alejandro Celaya
1697ef9306 Created QrCodeModal test 2018-11-01 12:35:51 +01:00
Alejandro Celaya
79a16a2c2c Created PreviewModal test 2018-11-01 12:34:18 +01:00
Alejandro Celaya
30192cb349 Created DeleteShortUrlModal test 2018-11-01 12:24:16 +01:00
Alejandro Celaya
8d0c0bcc99 Created CreateShortUrlResult test 2018-11-01 09:30:05 +01:00
Alejandro Celaya
70ebb0362a Converted DateInput into functional component 2018-11-01 09:16:18 +01:00
Alejandro Celaya
cccf57a35a Moved DateInput from common to utils 2018-11-01 09:05:20 +01:00
Alejandro Celaya
756e0c637e Merge pull request #69 from acelaya/feature/server-subpatch
Feature/server subpatch
2018-11-01 08:58:06 +01:00
Alejandro Celaya
44541d5e97 Fixed typo 2018-11-01 08:49:17 +01:00
Alejandro Celaya
655045c975 Documented how to build the project so that it can be served from a subpath 2018-11-01 08:46:54 +01:00
Alejandro Celaya
6784c30fa0 Ensured react router uses homepage defined in package.jsoin as basename 2018-11-01 08:20:33 +01:00
Alejandro Celaya
a65aadd4b2 Merge pull request #68 from acelaya/feature/chart-labels
Feature/chart labels
2018-10-30 20:48:21 +01:00
Alejandro Celaya
3c12bc1434 Updated changelog 2018-10-30 20:43:20 +01:00
Alejandro Celaya
822afa6db7 Ensured tooltips are not intersect for bar charts 2018-10-30 20:41:36 +01:00
Alejandro Celaya
0c1c471714 Fixed test 2018-10-30 07:45:57 +01:00
Alejandro Celaya
b1b215e84a Updated react dependency 2018-10-30 07:41:31 +01:00
Alejandro Celaya
7a63f737ac Updated sort icons in SortingDropdown 2018-10-30 07:35:35 +01:00
Alejandro Celaya
4adf618026 Merge pull request #67 from acelaya/feature/order-countries
Feature/order countries
2018-10-28 23:13:02 +01:00
Alejandro Celaya
f1c464fd3e Added unreleased entry in changelog 2018-10-28 23:08:46 +01:00
Alejandro Celaya
99833b51a9 Ensured dropdown item styles are not overriden for disabled items 2018-10-28 23:06:57 +01:00
Alejandro Celaya
05936c52b3 Added sorting to referrers bar graph 2018-10-28 23:04:52 +01:00
Alejandro Celaya
368de2b4c7 Added order control to countries graph 2018-10-28 22:54:08 +01:00
Alejandro Celaya
6634fc41c5 Fixed short urls dropdown menu not properly located 2018-10-28 21:51:54 +01:00
Alejandro Celaya
4ad8e909d4 Extracted sorting dropdown to its own component 2018-10-28 21:26:47 +01:00
Alejandro Celaya
56ad6d9e1b Added missing changes for v1.1.1 to changelog 2018-10-20 17:23:26 +02:00
Alejandro Celaya
169c69df2c Merge pull request #64 from acelaya/bugfix/graphs-height
Bugfix/graphs height
2018-10-19 21:12:38 +02:00
Alejandro Celaya
0e8631ae9d Updated GraphCard so that it automatically calculates the proper aspect ration for bar chart graphs 2018-10-19 20:27:25 +02:00
Alejandro Celaya
812e391e34 Moved helper functions in GraphCard outside of component function 2018-10-19 19:04:22 +02:00
Alejandro Celaya
4c1a044fd3 Merge pull request #62 from acelaya/bugfix/date-boxes-margin
Recovered missing class
2018-10-07 09:26:59 +02:00
Alejandro Celaya
bb17dbe680 Recovered missing class 2018-10-07 09:22:15 +02:00
Alejandro Celaya
160de66b44 Merge pull request #61 from acelaya/feature/automatic-release
Feature/automatic release
2018-10-07 09:15:01 +02:00
Alejandro Celaya
02b38cf84a Updated changelog including the automation of the release 2018-10-07 09:10:44 +02:00
Alejandro Celaya
2101dadfd7 Added release generation to travis deploy step 2018-10-07 09:09:40 +02:00
Alejandro Celaya
782a5c1d35 Merge pull request #60 from acelaya/bugfix/color-generator
Bugfix/color generator
2018-10-07 09:08:12 +02:00
Alejandro Celaya
de9f20b7a6 Added unreleased changes to changelog 2018-10-07 09:01:24 +02:00
Alejandro Celaya
644caf7dfb Ensured ColorGenerator matches keys in a case insensitive way 2018-10-07 08:59:25 +02:00
Alejandro Celaya
f26deb51eb Fixed typo in badge 2018-09-16 13:18:41 +02:00
115 changed files with 2697 additions and 1020 deletions

View File

@@ -1,5 +1,7 @@
language: node_js
sudo: false
node_js:
- "stable"
@@ -14,9 +16,20 @@ install:
script:
- yarn lint
- 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
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

View File

@@ -1,5 +1,83 @@
# 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

View File

@@ -1,4 +1,4 @@
FROM nginx:1.15.2-alpine
FROM nginx:1.15.7-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
# Install node and yarn

View File

@@ -1,7 +1,7 @@
# shlink-web-client
[![Build Status](https://img.shields.io/travis/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink-web-client)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/gshlinkio/shlink-web-client/?branch=master)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
@@ -21,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`).
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/)
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.

View File

@@ -1,2 +1,8 @@
#!/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 && $*"

View File

@@ -1,9 +1,12 @@
module.exports = {
coverageDirectory: '<rootDir>/coverage',
collectCoverageFrom: [
'src/**/*.{js,jsx,mjs}',
'src/**/*.js',
'!src/registerServiceWorker.js',
'!src/index.js',
'!src/reducers/index.js',
'!src/**/provideServices.js',
'!src/container/*.js',
],
setupFiles: [
'<rootDir>/config/polyfills.js',

View File

@@ -3,6 +3,7 @@
"description": "A React-based progressive web application for shlink",
"version": "1.0.0",
"private": false,
"homepage": "",
"scripts": {
"lint": "yarn lint:js && yarn lint:css",
"lint:js": "eslint src test scripts config",
@@ -10,6 +11,7 @@
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:css:fix": "yarn lint:css --fix",
"start": "node scripts/start.js",
"serve:build": "yarn serve ./build",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom --colors",
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
@@ -22,6 +24,7 @@
"@fortawesome/react-fontawesome": "0.0.19",
"axios": "^0.18.0",
"bootstrap": "^4.1.1",
"bottlejs": "^1.7.1",
"chart.js": "^2.7.2",
"classnames": "^2.2.6",
"csvjson": "^5.1.0",
@@ -30,7 +33,7 @@
"prop-types": "^15.6.2",
"qs": "^6.5.2",
"ramda": "^0.25.0",
"react": "^16.3.2",
"react": "^16.6",
"react-autosuggest": "^9.4.0",
"react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",

View File

@@ -4,17 +4,19 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#4696e5">
<!--
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/
-->
<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" 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="24x24" href="%PUBLIC_URL%/icons/shlink-24.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.
It will be replaced with the URL of the `public` folder during the build.

View File

@@ -1,23 +1,19 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
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() {
return (
<div className="container-fluid app-container">
<MainHeader />
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
<div className="container-fluid app-container">
<MainHeader />
<div className="app">
<Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
</Switch>
</div>
<div className="app">
<Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
</Switch>
</div>
);
}
</div>
);
export default App;

View File

@@ -6,9 +6,8 @@ import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import DeleteServerButton from '../servers/DeleteServerButton';
import './AsideMenu.scss';
import { serverType } from '../servers/prop-types';
import './AsideMenu.scss';
const defaultProps = {
className: '',
@@ -20,51 +19,57 @@ const propTypes = {
showOnMobile: PropTypes.bool,
};
export default function AsideMenu({ selectedServer, className, showOnMobile }) {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
server={selectedServer}
/>
</nav>
</aside>
);
}
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
server={selectedServer}
/>
</nav>
</aside>
);
};
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
return AsideMenu;
};
export default AsideMenu;

View File

@@ -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 { isNil } from 'ramda';
import './DateInput.scss';
export default class DateInput extends React.Component {
constructor(props) {
super(props);
this.inputRef = props.ref || React.createRef();
}
render() {
const { className, 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 ${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>
);
}
}

View File

@@ -1,15 +1,13 @@
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, pick, values } from 'ramda';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import PropTypes from 'prop-types';
import { resetSelectedServer } from '../servers/reducers/selectedServer';
import './Home.scss';
export class HomeComponent extends React.Component {
export default class Home extends React.Component {
static propTypes = {
resetSelectedServer: PropTypes.func,
servers: PropTypes.object,
@@ -50,7 +48,3 @@ export class HomeComponent extends React.Component {
);
}
}
const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent);
export default Home;

View File

@@ -2,15 +2,14 @@ import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
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 classnames from 'classnames';
import PropTypes from 'prop-types';
import ServersDropdown from '../servers/ServersDropdown';
import './MainHeader.scss';
import shlinkLogo from './shlink-logo-white.png';
import './MainHeader.scss';
export class MainHeaderComponent extends React.Component {
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
static propTypes = {
location: PropTypes.object,
};
@@ -62,8 +61,6 @@ export class MainHeaderComponent extends React.Component {
</Navbar>
);
}
}
const MainHeader = withRouter(MainHeaderComponent);
};
export default MainHeader;

View File

@@ -1,113 +1,100 @@
import React from 'react';
import { Route, Switch, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { pick } from 'ramda';
import { Route, Switch } from 'react-router-dom';
import Swipeable from 'react-swipeable';
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import * as PropTypes from 'prop-types';
import ShortUrlsVisits from '../visits/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 AsideMenu from './AsideMenu';
import './MenuLayout.scss';
export class MenuLayoutComponent extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
class MenuLayout extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
};
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
/* eslint react/no-deprecated: "off" */
componentWillMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer } = this.props;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={() => this.setState({ showSideBar: false })}
onSwipedRight={() => this.setState({ showSideBar: true })}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Switch>
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
};
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
/* eslint react/no-deprecated: "off" */
componentWillMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer } = this.props;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={() => this.setState({ showSideBar: false })}
onSwipedRight={() => this.setState({ showSideBar: true })}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Switch>
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlsVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
}
const MenuLayout = compose(
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
withRouter
)(MenuLayoutComponent);
export default MenuLayout;

View File

@@ -1,21 +1,14 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
export class ScrollToTopComponent extends React.Component {
const ScrollToTop = (window) => class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
window: PropTypes.shape({
scrollTo: PropTypes.func,
}),
children: PropTypes.node,
};
static defaultProps = {
window,
};
componentDidUpdate(prevProps) {
const { location, window } = this.props;
const { location } = this.props;
if (location !== prevProps.location) {
window.scrollTo(0, 0);
@@ -25,8 +18,6 @@ export class ScrollToTopComponent extends React.Component {
render() {
return this.props.children;
}
}
const ScrollToTop = withRouter(ScrollToTopComponent);
};
export default ScrollToTop;

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

View File

@@ -1,28 +1,21 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import React from 'react';
import ReactDOM from 'react-dom';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import ReduxThunk from 'redux-thunk';
import App from './App';
import './index.scss';
import ScrollToTop from './common/ScrollToTop';
import reducers from './reducers';
import { homepage } from '../package.json';
import registerServiceWorker from './registerServiceWorker';
import container from './container';
import store from './container/store';
import '../node_modules/react-datepicker/dist/react-datepicker.css';
import './common/react-tagsinput.scss';
import './index.scss';
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)
));
const { App, ScrollToTop } = container;
ReactDOM.render(
render(
<Provider store={store}>
<BrowserRouter>
<BrowserRouter basename={homepage}>
<ScrollToTop>
<App />
</ScrollToTop>

View File

@@ -18,12 +18,12 @@ body,
background-color: $mainColor !important;
}
.dropdown-item {
.dropdown-item:not(:disabled) {
cursor: pointer;
}
.dropdown-item.active,
.dropdown-item:active {
.dropdown-item.active:not(:disabled),
.dropdown-item:active:not(:disabled) {
background-color: $lightGrey !important;
color: inherit !important;
}

View File

@@ -1,17 +1,13 @@
import { assoc, dissoc, pick, pipe } from 'ramda';
import { assoc, dissoc, pipe } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { stateFlagTimeout } from '../utils/utils';
import { resetSelectedServer } from './reducers/selectedServer';
import { createServer } from './reducers/server';
import './CreateServer.scss';
import ImportServersBtn from './helpers/ImportServersBtn';
const SHOW_IMPORT_MSG_TIME = 4000;
export class CreateServerComponent extends React.Component {
const CreateServer = (ImportServersBtn) => class CreateServer extends React.Component {
static propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
@@ -91,11 +87,6 @@ export class CreateServerComponent extends React.Component {
</div>
);
}
}
const CreateServer = connect(
pick([ 'selectedServer' ]),
{ createServer, resetSelectedServer }
)(CreateServerComponent);
};
export default CreateServer;

View File

@@ -2,10 +2,9 @@ import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types';
import DeleteServerModal from './DeleteServerModal';
import { serverType } from './prop-types';
export default class DeleteServerButton extends React.Component {
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
static propTypes = {
server: serverType,
className: PropTypes.string,
@@ -36,4 +35,6 @@ export default class DeleteServerButton extends React.Component {
</React.Fragment>
);
}
}
};
export default DeleteServerButton;

View File

@@ -1,10 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { compose } from 'redux';
import { deleteServer } from './reducers/server';
import { serverType } from './prop-types';
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 = () => {
deleteServer(server);
toggle();
@@ -42,11 +38,6 @@ export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServe
);
};
DeleteServerModalComponent.propTypes = propTypes;
const DeleteServerModal = compose(
withRouter,
connect(null, { deleteServer })
)(DeleteServerModalComponent);
DeleteServerModal.propTypes = propTypes;
export default DeleteServerModal;

View File

@@ -1,30 +1,20 @@
import { isEmpty, pick, values } from 'ramda';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
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';
export class ServersDropdownComponent extends React.Component {
static defaultProps = {
serversExporter,
};
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
static propTypes = {
servers: PropTypes.object,
serversExporter: PropTypes.shape({
exportServers: PropTypes.func,
}),
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
};
renderServers = () => {
const { servers, selectedServer, selectServer, serversExporter } = this.props;
const { servers, selectedServer, selectServer } = this.props;
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
@@ -68,11 +58,6 @@ export class ServersDropdownComponent extends React.Component {
</UncontrolledDropdown>
);
}
}
const ServersDropdown = connect(
pick([ 'servers', 'selectedServer' ]),
{ listServers, selectServer }
)(ServersDropdownComponent);
};
export default ServersDropdown;

View File

@@ -1,20 +1,15 @@
import React from 'react';
import { connect } from 'react-redux';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc } from 'ramda';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { createServers } from '../reducers/server';
import serversImporter, { serversImporterType } from '../services/ServersImporter';
export class ImportServersBtnComponent extends React.Component {
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
static defaultProps = {
serversImporter,
onImport: () => ({}),
};
static propTypes = {
onImport: PropTypes.func,
serversImporter: serversImporterType,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
@@ -25,7 +20,8 @@ export class ImportServersBtnComponent extends React.Component {
}
render() {
const { serversImporter: { importServersFromFile }, onImport, createServers } = this.props;
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const onChange = (e) =>
importServersFromFile(e.target.files[0])
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
@@ -56,8 +52,6 @@ export class ImportServersBtnComponent extends React.Component {
</React.Fragment>
);
}
}
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
};
export default ImportServersBtn;

View File

@@ -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';
/* 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 _selectServer = (shlinkApiClient, serversService, serverId) => (dispatch) => {
export const selectServer = (serversService) => (serverId) => (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = serversService.findServerById(serverId);
shlinkApiClient.setConfig(selectedServer);
dispatch({
type: SELECT_SERVER,
selectedServer,
});
};
export const selectServer = curry(_selectServer)(shlinkApiClient, serversService);

View File

@@ -1,6 +1,3 @@
import { curry } from 'ramda';
import serversService from '../services/ServersService';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
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,
servers: serversService.listServers(),
});
export const listServers = () => _listServers(serversService);
export const _createServer = (serversService, server) => {
export const createServer = (serversService) => (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);
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);
return _listServers(serversService);
return listServers(serversService)();
};
export const createServers = curry(_createServers)(serversService);

View File

@@ -1,6 +1,4 @@
import { dissoc, head, keys, values } from 'ramda';
import csvjson from 'csvjson';
import serversService from './ServersService';
const saveCsv = (window, csv) => {
const { navigator, document } = window;
@@ -26,7 +24,7 @@ const saveCsv = (window, csv) => {
document.body.removeChild(link);
};
export class ServersExporter {
export default class ServersExporter {
constructor(serversService, window, csvjson) {
this.serversService = serversService;
this.window = window;
@@ -49,7 +47,3 @@ export class ServersExporter {
}
};
}
const serverExporter = new ServersExporter(serversService, global.window, csvjson);
export default serverExporter;

View File

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

View File

@@ -1,9 +1,8 @@
import { assoc, dissoc, reduce } from 'ramda';
import storage from '../../utils/Storage';
const SERVERS_STORAGE_KEY = 'servers';
export class ServersService {
export default class ServersService {
constructor(storage) {
this.storage = storage;
}
@@ -30,7 +29,3 @@ export class ServersService {
dissoc(server.id, this.listServers())
);
}
const serversService = new ServersService(storage);
export default serversService;

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

View File

@@ -1,16 +1,24 @@
import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown';
import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
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 { connect } from 'react-redux';
import { Collapse } from 'reactstrap';
import DateInput from '../common/DateInput';
import TagsSelector from '../tags/helpers/TagsSelector';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreation';
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 = {
longUrl: '',
tags: [],
@@ -24,27 +32,31 @@ export class CreateShortUrlComponent extends React.Component {
render() {
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 = {}) => (
<input
className="form-control"
type={type}
placeholder={placeholder}
value={this.state[id]}
onChange={(e) => this.setState({ [id]: e.target.value })}
{...props}
/>
<div className="form-group">
<input
className="form-control"
id={id}
type={type}
placeholder={placeholder}
value={this.state[id]}
onChange={(e) => this.setState({ [id]: e.target.value })}
{...props}
/>
</div>
);
const createDateInput = (id, placeholder, props = {}) => (
<DateInput
selected={this.state[id]}
placeholderText={placeholder}
isClearable
onChange={(date) => this.setState({ [id]: date })}
{...props}
/>
const renderDateInput = (id, placeholder, props = {}) => (
<div className="form-group">
<DateInput
selected={this.state[id]}
placeholderText={placeholder}
isClearable
onChange={(date) => this.setState({ [id]: date })}
{...props}
/>
</div>
);
const formatDate = (date) => isNil(date) ? date : date.format();
const save = (e) => {
e.preventDefault();
createShortUrl(pipe(
@@ -75,20 +87,12 @@ export class CreateShortUrlComponent extends React.Component {
<div className="row">
<div className="col-sm-6">
<div className="form-group">
{renderOptionalInput('customSlug', 'Custom slug')}
</div>
<div className="form-group">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
{renderOptionalInput('customSlug', 'Custom slug')}
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-6">
<div className="form-group">
{createDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
</div>
<div className="form-group">
{createDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div>
</Collapse>
@@ -116,11 +120,6 @@ export class CreateShortUrlComponent extends React.Component {
</div>
);
}
}
const CreateShortUrl = connect(pick([ 'shortUrlCreationResult' ]), {
createShortUrl,
resetCreateShortUrl,
})(CreateShortUrlComponent);
};
export default CreateShortUrl;

View File

@@ -1,55 +1,56 @@
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import { connect } from 'react-redux';
import { isEmpty, pick } from 'ramda';
import { isEmpty } from 'ramda';
import PropTypes from 'prop-types';
import Tag from '../tags/helpers/Tag';
import SearchField from '../utils/SearchField';
import { listShortUrls } from './reducers/shortUrlsList';
import './SearchBar.scss';
import Tag from '../tags/helpers/Tag';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
const propTypes = {
listShortUrls: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
};
export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
const selectedTags = shortUrlsListParams.tags || [];
const SearchBar = (colorGenerator) => {
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
const selectedTags = shortUrlsListParams.tags || [];
return (
<div className="serach-bar-container">
<SearchField onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
/>
return (
<div className="serach-bar-container">
<SearchField onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
/>
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) => (
<Tag
key={tag}
text={tag}
clearable
onClose={() => listShortUrls(
{
...shortUrlsListParams,
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
}
)}
/>
))}
</h4>
)}
</div>
);
}
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
clearable
onClose={() => listShortUrls(
{
...shortUrlsListParams,
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
}
)}
/>
))}
</h4>
)}
</div>
);
};
SearchBarComponent.propTypes = propTypes;
SearchBar.propTypes = propTypes;
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent);
return SearchBar;
};
export default SearchBar;

View File

@@ -1,27 +1,21 @@
import React from 'react';
import { connect } from 'react-redux';
import { assoc } from 'ramda';
import Paginator from './Paginator';
import SearchBar from './SearchBar';
import ShortUrlsList from './ShortUrlsList';
export function ShortUrlsComponent(props) {
const { match: { params } } = props;
const ShortUrls = (SearchBar, ShortUrlsList) => (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
const urlsListKey = `${params.serverId}_${params.page}`;
const urlsListKey = `${serverId}_${page}`;
return (
<div className="shlink-container">
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} />
<Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} />
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
);
}
const ShortUrls = connect(
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
)(ShortUrlsComponent);
};
export default ShortUrls;

View File

@@ -1,17 +1,16 @@
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
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 { connect } from 'react-redux';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import qs from 'qs';
import PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils';
import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss';
import { shortUrlsListParamsType, resetShortUrlParams } from './reducers/shortUrlsListParams';
const SORTABLE_FIELDS = {
dateCreated: 'Created at',
@@ -20,7 +19,7 @@ const SORTABLE_FIELDS = {
visits: 'Visits',
};
export class ShortUrlsListComponent extends React.Component {
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
static propTypes = {
listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func,
@@ -41,25 +40,13 @@ export class ShortUrlsListComponent extends React.Component {
...extraParams,
});
};
determineOrderDir = (field) => {
if (this.state.orderField !== field) {
return 'ASC';
}
const newOrderMap = {
ASC: 'DESC',
DESC: undefined,
};
return this.state.orderDir ? newOrderMap[this.state.orderDir] : 'ASC';
handleOrderBy = (orderField, orderDir) => {
this.setState({ orderField, orderDir });
this.refreshList({ orderBy: { [orderField]: orderDir } });
};
orderBy = (field) => {
const newOrderDir = this.determineOrderDir(field);
this.setState({ orderField: newOrderDir !== undefined ? field : undefined, orderDir: newOrderDir });
this.refreshList({ orderBy: { [field]: newOrderDir } });
};
renderOrderIcon = (field, className = 'short-urls-list__header-icon') => {
orderByColumn = (columnName) => () =>
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
renderOrderIcon = (field) => {
if (this.state.orderField !== field) {
return null;
}
@@ -67,7 +54,7 @@ export class ShortUrlsListComponent extends React.Component {
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className={className}
className="short-urls-list__header-icon"
/>
);
};
@@ -126,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() {
return (
<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">
<thead className="short-urls-list__header">
<tr>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('dateCreated')}
onClick={this.orderByColumn('dateCreated')}
>
{this.renderOrderIcon('dateCreated')}
Created at
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('shortCode')}
onClick={this.orderByColumn('shortCode')}
>
{this.renderOrderIcon('shortCode')}
Short URL
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('originalUrl')}
onClick={this.orderByColumn('originalUrl')}
>
{this.renderOrderIcon('originalUrl')}
Long URL
@@ -177,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 short-urls-list__header-cell--with-action"
onClick={() => this.orderBy('visits')}
onClick={this.orderByColumn('visits')}
>
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
</th>
@@ -191,11 +165,6 @@ export class ShortUrlsListComponent extends React.Component {
</React.Fragment>
);
}
}
const ShortUrlsList = connect(
pick([ 'selectedServer', 'shortUrlsListParams' ]),
{ listShortUrls, resetShortUrlParams }
)(ShortUrlsListComponent);
};
export default ShortUrlsList;

View File

@@ -14,15 +14,6 @@
margin-right: 5px;
}
.short-urls-list__header-icon--mobile {
margin: 3.5px 0 0;
float: right;
}
.short-urls-list__header-cell--with-action {
cursor: pointer;
}
.short-urls-list__order-dropdown {
width: 100%;
}

View File

@@ -1,18 +1,11 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick, identity } from 'ramda';
import { identity } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import {
deleteShortUrl,
resetDeleteShortUrl,
shortUrlDeleted,
shortUrlDeletionType,
} from '../reducers/shortUrlDeletion';
import './QrCodeModal.scss';
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
export class DeleteShortUrlModalComponent extends Component {
export default class DeleteShortUrlModal extends React.Component {
static propTypes = {
shortUrl: shortUrlType,
toggle: PropTypes.func,
@@ -47,6 +40,8 @@ export class DeleteShortUrlModalComponent extends Component {
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>
@@ -66,12 +61,12 @@ export class DeleteShortUrlModalComponent extends Component {
onChange={(e) => this.setState({ inputValue: e.target.value })}
/>
{shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED && (
{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>
)}
{shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED && (
{hasErrorOtherThanThreshold && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :(
</div>
@@ -92,10 +87,3 @@ export class DeleteShortUrlModalComponent extends Component {
);
}
}
const DeleteShortUrlModal = connect(
pick([ 'shortUrlDeletion' ]),
{ deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted }
)(DeleteShortUrlModalComponent);
export default DeleteShortUrlModal;

View File

@@ -1,19 +1,11 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import TagsSelector from '../../tags/helpers/TagsSelector';
import {
editShortUrlTags,
resetShortUrlsTags,
shortUrlTagsType,
shortUrlTagsEdited,
} from '../reducers/shortUrlTags';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList';
export class EditTagsModalComponent extends React.Component {
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
@@ -88,11 +80,6 @@ export class EditTagsModalComponent extends React.Component {
</Modal>
);
}
}
const EditTagsModal = connect(
pick([ 'shortUrlTags' ]),
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }
)(EditTagsModalComponent);
};
export default EditTagsModal;

View File

@@ -10,20 +10,20 @@ const propTypes = {
isOpen: PropTypes.bool,
};
export default function PreviewModal({ url, toggle, isOpen }) {
return (
<Modal isOpen={isOpen} toggle={toggle} size="lg">
<ModalHeader toggle={toggle}>
Preview for <ExternalLink href={url}>{url}</ExternalLink>
</ModalHeader>
<ModalBody>
<div className="text-center">
<p className="preview-modal__loader">Loading...</p>
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
</div>
</ModalBody>
</Modal>
);
}
const PreviewModal = ({ url, toggle, isOpen }) => (
<Modal isOpen={isOpen} toggle={toggle} size="lg">
<ModalHeader toggle={toggle}>
Preview for <ExternalLink href={url}>{url}</ExternalLink>
</ModalHeader>
<ModalBody>
<div className="text-center">
<p className="preview-modal__loader">Loading...</p>
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
</div>
</ModalBody>
</Modal>
);
PreviewModal.propTypes = propTypes;
export default PreviewModal;

View File

@@ -10,19 +10,19 @@ const propTypes = {
isOpen: PropTypes.bool,
};
export default function QrCodeModal({ url, toggle, isOpen }) {
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
QR code for <ExternalLink href={url}>{url}</ExternalLink>
</ModalHeader>
<ModalBody>
<div className="text-center">
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
</div>
</ModalBody>
</Modal>
);
}
const QrCodeModal = ({ url, toggle, isOpen }) => (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
QR code for <ExternalLink href={url}>{url}</ExternalLink>
</ModalHeader>
<ModalBody>
<div className="text-center">
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
</div>
</ModalBody>
</Modal>
);
QrCodeModal.propTypes = propTypes;
export default QrCodeModal;

View File

@@ -2,16 +2,15 @@ import { isEmpty } from 'ramda';
import React from 'react';
import Moment from 'react-moment';
import PropTypes from 'prop-types';
import Tag from '../../tags/helpers/Tag';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
import { stateFlagTimeout } from '../../utils/utils';
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu';
import Tag from '../../tags/helpers/Tag';
import './ShortUrlsRow.scss';
export class ShortUrlsRow extends React.Component {
const ShortUrlsRow = (ShortUrlsRowMenu, colorGenerator) => class ShortUrlsRow extends React.Component {
static propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
@@ -31,6 +30,7 @@ export class ShortUrlsRow extends React.Component {
return tags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
@@ -72,4 +72,6 @@ export class ShortUrlsRow extends React.Component {
</tr>
);
}
}
};
export default ShortUrlsRow;

View File

@@ -16,10 +16,8 @@ import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import './ShortUrlsRowMenu.scss';
import EditTagsModal from './EditTagsModal';
import DeleteShortUrlModal from './DeleteShortUrlModal';
export class ShortUrlsRowMenu extends React.Component {
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
completeShortUrl: PropTypes.string,
onCopyToClipboard: PropTypes.func,
@@ -46,11 +44,11 @@ export class ShortUrlsRowMenu extends React.Component {
const toggleDelete = toggleModal('isDeleteModalOpen');
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">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu>
<DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
</DropdownItem>
@@ -105,4 +103,6 @@ export class ShortUrlsRowMenu extends React.Component {
</ButtonDropdown>
);
}
}
};
export default ShortUrlsRowMenu;

View File

@@ -1,6 +1,6 @@
@import '../../utils/base';
.short-urls-row-menu__dropdown-toggle:before {
.short-urls-row-menu__dropdown-toggle:after {
display: none !important;
}

View File

@@ -1,12 +1,10 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const createShortUrlResultType = PropTypes.shape({
@@ -29,6 +27,7 @@ export default function reducer(state = defaultState, action) {
return {
...state,
saving: true,
error: false,
};
case CREATE_SHORT_URL_ERROR:
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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
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 });

View File

@@ -1,12 +1,10 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
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 */
@@ -56,11 +54,14 @@ export default function reducer(state = defaultState, action) {
}
}
export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch) => {
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START });
const { selectedServer } = getState();
const { deleteShortUrl } = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.deleteShortUrl(shortCode);
await deleteShortUrl(shortCode);
dispatch({ type: DELETE_SHORT_URL, shortCode });
} catch (e) {
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
@@ -69,8 +70,6 @@ export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch)
}
};
export const deleteShortUrl = curry(_deleteShortUrl)(shlinkApiClient);
export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL });
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View File

@@ -1,6 +1,5 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
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:
return {
shortCode: action.shortCode,
tags: action.tags,
...pick([ 'shortCode', 'tags' ], action),
saving: false,
error: false,
};
@@ -52,8 +50,10 @@ 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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags);
@@ -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 shortUrlTagsEdited = (shortCode, tags) => ({

View File

@@ -1,12 +1,11 @@
import { assoc, assocPath, reject } from 'ramda';
import { assoc, assocPath, propEq, reject } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
/* eslint-disable padding-line-between-statements, newline-after-var */
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_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
/* eslint-enable padding-line-between-statements, newline-after-var */
@@ -19,6 +18,7 @@ export const shortUrlType = PropTypes.shape({
const initialState = {
shortUrls: {},
loading: true,
error: false,
};
export default function reducer(state = initialState, action) {
@@ -35,7 +35,7 @@ export default function reducer(state = initialState, action) {
return {
loading: false,
error: true,
shortUrls: [],
shortUrls: {},
};
case SHORT_URL_TAGS_EDITED:
const { data } = state.shortUrls;
@@ -47,7 +47,7 @@ export default function reducer(state = initialState, action) {
case SHORT_URL_DELETED:
return assocPath(
[ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrl.shortCode === action.shortCode, state.shortUrls.data),
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
state,
);
default:
@@ -55,16 +55,17 @@ export default function reducer(state = initialState, action) {
}
}
export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch) => {
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START });
const { selectedServer = {} } = getState();
const { listShortUrls } = buildShlinkApiClient(selectedServer);
try {
const shortUrls = await shlinkApiClient.listShortUrls(params);
const shortUrls = await listShortUrls(params);
dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
} catch (e) {
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
}
};
export const listShortUrls = (params = {}) => _listShortUrls(shlinkApiClient, params);

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

View File

@@ -7,10 +7,8 @@ import React from 'react';
import { Link } from 'react-router-dom';
import TagBullet from './helpers/TagBullet';
import './TagCard.scss';
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
import EditTagModal from './helpers/EditTagModal';
export default class TagCard extends React.Component {
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
static propTypes = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
@@ -28,37 +26,23 @@ export default class TagCard extends React.Component {
return (
<Card className="tag-card">
<CardBody className="tag-card__body">
<button
className="btn btn-light btn-sm tag-card__btn tag-card__btn--last"
onClick={toggleDelete}
>
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} />
</button>
<button
className="btn btn-light btn-sm tag-card__btn"
onClick={toggleEdit}
>
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</button>
<h5 className="tag-card__tag-title">
<TagBullet tag={tag} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>
{tag}
</Link>
<TagBullet tag={tag} colorGenerator={colorGenerator} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
</h5>
</CardBody>
<DeleteTagConfirmModal
tag={tag}
toggle={toggleDelete}
isOpen={this.state.isDeleteModalOpen}
/>
<EditTagModal
tag={tag}
toggle={toggleEdit}
isOpen={this.state.isEditModalOpen}
/>
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
</Card>
);
}
}
};
export default TagCard;

View File

@@ -1,21 +1,20 @@
import React from 'react';
import { connect } from 'react-redux';
import { pick, splitEvery } from 'ramda';
import { splitEvery } from 'ramda';
import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage';
import SearchField from '../utils/SearchField';
import { filterTags, forceListTags } from './reducers/tagsList';
import TagCard from './TagCard';
const { ceil } = Math;
const TAGS_GROUP_SIZE = 4;
const TAGS_GROUPS_AMOUNT = 4;
export class TagsListComponent extends React.Component {
const TagsList = (TagCard) => class TagsList extends React.Component {
static propTypes = {
filterTags: PropTypes.func,
forceListTags: PropTypes.func,
tagsList: PropTypes.shape({
loading: PropTypes.bool,
error: PropTypes.bool,
filteredTags: PropTypes.arrayOf(PropTypes.string),
}),
match: PropTypes.object,
};
@@ -23,7 +22,7 @@ export class TagsListComponent extends React.Component {
componentDidMount() {
const { forceListTags } = this.props;
forceListTags(true);
forceListTags();
}
renderContent() {
@@ -47,7 +46,7 @@ export class TagsListComponent extends React.Component {
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 (
<React.Fragment>
@@ -71,21 +70,15 @@ export class TagsListComponent extends React.Component {
return (
<div className="shlink-container">
{!this.props.tagsList.loading && (
<SearchField
className="mb-3"
placeholder="Search tags..."
onChange={filterTags}
/>
)}
{!this.props.tagsList.loading &&
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
}
<div className="row">
{this.renderContent()}
</div>
</div>
);
}
}
const TagsList = connect(pick([ 'tagsList' ]), { forceListTags, filterTags })(TagsListComponent);
};
export default TagsList;

View File

@@ -1,11 +1,9 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete';
import { tagDeleteType } from '../reducers/tagDelete';
export class DeleteTagConfirmModalComponent extends React.Component {
export default class DeleteTagConfirmModal extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
@@ -67,10 +65,3 @@ export class DeleteTagConfirmModalComponent extends React.Component {
);
}
}
const DeleteTagConfirmModal = connect(
pick([ 'tagDelete' ]),
{ deleteTag, tagDeleted }
)(DeleteTagConfirmModalComponent);
export default DeleteTagConfirmModal;

View File

@@ -1,31 +1,23 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { pick } from 'ramda';
import { ChromePicker } from 'react-color';
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import { editTag, tagEdited } from '../reducers/tagEdit';
import './EditTagModal.scss';
export class EditTagModalComponent extends React.Component {
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
static propTypes = {
tag: PropTypes.string,
editTag: PropTypes.func,
toggle: PropTypes.func,
tagEdited: PropTypes.func,
colorGenerator: colorGeneratorType,
isOpen: PropTypes.bool,
tagEdit: PropTypes.shape({
error: PropTypes.bool,
editing: PropTypes.bool,
}),
};
static defaultProps = {
colorGenerator,
};
saveTag = (e) => {
e.preventDefault();
@@ -53,12 +45,12 @@ export class EditTagModalComponent extends React.Component {
constructor(props) {
super(props);
const { colorGenerator, tag } = props;
const { tag } = props;
this.state = {
showColorPicker: false,
tag,
color: colorGenerator.getColorForKey(tag),
color: getColorForKey(tag),
};
}
@@ -131,8 +123,6 @@ export class EditTagModalComponent extends React.Component {
</Modal>
);
}
}
const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent);
};
export default EditTagModal;

View File

@@ -1,41 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './Tag.scss';
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
const propTypes = {
colorGenerator: colorGeneratorType,
text: PropTypes.string,
children: PropTypes.node,
clearable: PropTypes.bool,
colorGenerator: colorGeneratorType,
onClick: PropTypes.func,
onClose: PropTypes.func,
};
const defaultProps = {
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}>&times;</span>}
</span>
);
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}>&times;</span>}
</span>
);
}
Tag.defaultProps = defaultProps;
Tag.propTypes = propTypes;
export default Tag;

View File

@@ -1,24 +1,20 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
import './TagBullet.scss';
const propTypes = {
tag: PropTypes.string.isRequired,
colorGenerator: colorGeneratorType,
};
const defaultProps = {
colorGenerator,
};
export default function TagBullet({ tag, colorGenerator }) {
return (
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-bullet"
/>
);
}
const TagBullet = ({ tag, colorGenerator }) => (
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-bullet"
/>
);
TagBullet.propTypes = propTypes;
TagBullet.defaultProps = defaultProps;
export default TagBullet;

View File

@@ -1,26 +1,21 @@
import React from 'react';
import { connect } from 'react-redux';
import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types';
import Autosuggest from 'react-autosuggest';
import { pick, identity } from 'ramda';
import { listTags } from '../reducers/tagsList';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './TagsSelector.scss';
import { identity } from 'ramda';
import TagBullet from './TagBullet';
import './TagsSelector.scss';
export class TagsSelectorComponent extends React.Component {
const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
colorGenerator: colorGeneratorType,
tagsList: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
}),
};
static defaultProps = {
colorGenerator,
placeholder: 'Add tags to the URL',
};
@@ -31,7 +26,7 @@ export class TagsSelectorComponent extends React.Component {
}
render() {
const { tags, onChange, placeholder, colorGenerator, tagsList } = this.props;
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)}
@@ -59,7 +54,7 @@ export class TagsSelectorComponent extends React.Component {
getSuggestionValue={(suggestion) => suggestion}
renderSuggestion={(suggestion) => (
<React.Fragment>
<TagBullet tag={suggestion} />
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
{suggestion}
</React.Fragment>
)}
@@ -86,8 +81,6 @@ export class TagsSelectorComponent extends React.Component {
/>
);
}
}
const TagsSelector = connect(pick([ 'tagsList' ]), { listTags })(TagsSelectorComponent);
};
export default TagsSelector;

View File

@@ -1,11 +1,9 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
@@ -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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.deleteTags([ 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 });

View File

@@ -1,11 +1,9 @@
import { curry, pick } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import colorGenerator from '../../utils/ColorGenerator';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
/* eslint-enable padding-line-between-statements, newline-after-var */
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) =>
async (dispatch) => {
dispatch({ type: EDIT_TAG_START });
export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newName, color) => async (
dispatch,
getState
) => {
dispatch({ type: EDIT_TAG_START });
try {
await shlinkApiClient.editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) {
dispatch({ type: EDIT_TAG_ERROR });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
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) => ({
type: TAG_EDITED,

View File

@@ -1,5 +1,5 @@
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_EDITED } from './tagEdit';
@@ -66,8 +66,8 @@ export default function reducer(state = defaultState, action) {
}
}
export const _listTags = (shlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList } = getState();
export const _listTags = (buildShlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList, selectedServer } = getState();
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
return;
@@ -76,6 +76,7 @@ export const _listTags = (shlinkApiClient, force = false) => async (dispatch, ge
dispatch({ type: LIST_TAGS_START });
try {
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const tags = await shlinkApiClient.listTags();
dispatch({ tags, type: LIST_TAGS });
@@ -84,9 +85,9 @@ export const _listTags = (shlinkApiClient, force = false) => async (dispatch, ge
}
};
export const listTags = () => _listTags(shlinkApiClient);
export const listTags = () => _listTags(buildShlinkApiClient);
export const forceListTags = () => _listTags(shlinkApiClient, true);
export const forceListTags = () => _listTags(buildShlinkApiClient, true);
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,

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

View File

@@ -1,5 +1,5 @@
@import '../utils/mixins/vertical-align';
@import '../utils/base';
@import './mixins/vertical-align';
@import './base';
.date-input-container {
position: relative;

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

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

View File

@@ -1,6 +1,5 @@
import { range } from 'ramda';
import PropTypes from 'prop-types';
import storage from './Storage';
const HEX_COLOR_LENGTH = 6;
const { floor, random } = Math;
@@ -11,29 +10,33 @@ const buildRandomColor = () =>
.map(() => letters[floor(random() * letters.length)])
.join('')
}`;
const normalizeKey = (key) => key.toLowerCase().trim();
export class ColorGenerator {
export default class ColorGenerator {
constructor(storage) {
this.storage = storage;
this.colors = this.storage.get('colors') || {};
}
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 (!color) {
this.setColorForKey(key, buildRandomColor());
return this.getColorForKey(key);
return this.setColorForKey(normalizedKey, buildRandomColor());
}
return color;
};
setColorForKey = (key, color) => {
this.colors[key] = color;
const normalizedKey = normalizeKey(key);
this.colors[normalizedKey] = color;
this.storage.set('colors', this.colors);
return color;
}
}
@@ -41,7 +44,3 @@ export const colorGeneratorType = PropTypes.shape({
getColorForKey: PropTypes.func,
setColorForKey: PropTypes.func,
});
const colorGenerator = new ColorGenerator(storage);
export default colorGenerator;

View File

@@ -1,26 +1,18 @@
import axios from 'axios';
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
const STATUS_UNAUTHORIZED = 401;
const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export class ShlinkApiClient {
constructor(axios) {
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
this.axios = axios;
this._baseUrl = '';
this._apiKey = '';
this._baseUrl = buildRestUrl(baseUrl);
this._apiKey = apiKey || '';
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 = {}) =>
this._performRequest('/short-codes', 'GET', options)
.then((resp) => resp.data.shortUrls)
@@ -113,7 +105,3 @@ export class ShlinkApiClient {
return Promise.reject(e);
};
}
const shlinkApiClient = new ShlinkApiClient(axios);
export default shlinkApiClient;

View 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);

View File

@@ -1,7 +1,7 @@
const PREFIX = 'shlink';
const buildPath = (path) => `${PREFIX}.${path}`;
export class Storage {
export default class Storage {
constructor(localStorage) {
this.localStorage = localStorage;
}
@@ -14,15 +14,3 @@ export class Storage {
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;

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

View File

@@ -4,3 +4,16 @@ export const stateFlagTimeout = (setState, flagName, initialValue = true, delay
setState({ [flagName]: initialValue });
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';
};

View File

@@ -6,53 +6,92 @@ 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 : () => {},
};
export function GraphCard({ title, isBarChart, stats }) {
const generateGraphData = (stats) => ({
labels: keys(stats),
datasets: [
{
title,
data: 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 renderGraph = () => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const options = {
legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart ? {
xAxes: [
{
ticks: { beginAtZero: true },
},
],
} : null,
};
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,
},
],
});
return <Component data={generateGraphData(stats)} options={options} />;
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;
}
};
return (
<Card className="mt-4">
<CardHeader>{title}</CardHeader>
<CardBody>{renderGraph()}</CardBody>
</Card>
);
}
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;

View File

@@ -1,30 +1,25 @@
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, pick } from 'ramda';
import { isEmpty, mapObjIndexed } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
import DateInput from '../common/DateInput';
import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
import {
processBrowserStats,
processCountriesStats,
processOsStats,
processReferrersStats,
} from './services/VisitsParser';
import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import { VisitsHeader } from './VisitsHeader';
import { GraphCard } from './GraphCard';
import { getShortUrlDetail, shortUrlDetailType } from './reducers/shortUrlDetail';
import GraphCard from './GraphCard';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
export class ShortUrlsVisitsComponent extends React.Component {
const ShortUrlVisits = ({
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
}) => class ShortUrlVisits extends React.Component {
static propTypes = {
processOsStats: PropTypes.func,
processBrowserStats: PropTypes.func,
processCountriesStats: PropTypes.func,
processReferrersStats: PropTypes.func,
match: PropTypes.shape({
params: PropTypes.object,
}),
@@ -33,12 +28,6 @@ export class ShortUrlsVisitsComponent extends React.Component {
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
};
static defaultProps = {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
@@ -58,14 +47,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
}
render() {
const {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
shortUrlVisits,
shortUrlDetail,
} = this.props;
const { shortUrlVisits, shortUrlDetail } = this.props;
const renderVisitsContent = () => {
const { visits, loading, error } = shortUrlVisits;
@@ -95,10 +77,24 @@ export class ShortUrlsVisitsComponent extends React.Component {
<GraphCard title="Browsers" stats={processBrowserStats(visits)} />
</div>
<div className="col-md-6">
<GraphCard title="Countries" stats={processCountriesStats(visits)} isBarChart />
<SortableBarGraph
stats={processCountriesStats(visits)}
title="Countries"
sortingItems={{
name: 'Country name',
amount: 'Visits amount',
}}
/>
</div>
<div className="col-md-6">
<GraphCard title="Referrers" stats={processReferrersStats(visits)} isBarChart />
<SortableBarGraph
stats={processReferrersStats(visits)}
title="Referrers"
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
/>
</div>
</div>
);
@@ -121,6 +117,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
</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
@@ -137,11 +134,6 @@ export class ShortUrlsVisitsComponent extends React.Component {
</div>
);
}
}
};
const ShortUrlsVisits = connect(
pick([ 'shortUrlVisits', 'shortUrlDetail' ]),
{ getShortUrlVisits, getShortUrlDetail }
)(ShortUrlsVisitsComponent);
export default ShortUrlsVisits;
export default ShortUrlVisits;

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

View File

@@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { shortUrlType } from '../../short-urls/reducers/shortUrlsList';
/* eslint-disable padding-line-between-statements, newline-after-var */
@@ -45,9 +43,12 @@ export default function reducer(state = initialState, action) {
}
}
export const _getShortUrlDetail = (shlinkApiClient, shortCode) => async (dispatch) => {
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);
@@ -56,5 +57,3 @@ export const _getShortUrlDetail = (shlinkApiClient, shortCode) => async (dispatc
dispatch({ type: GET_SHORT_URL_DETAIL_ERROR });
}
};
export const getShortUrlDetail = curry(_getShortUrlDetail)(shlinkApiClient);

View File

@@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
@@ -44,9 +42,12 @@ export default function reducer(state = initialState, action) {
}
}
export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => async (dispatch) => {
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);
@@ -55,5 +56,3 @@ export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => async (
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
}
};
export const getShortUrlVisits = curry(_getShortUrlVisits)(shlinkApiClient);

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

View File

@@ -1,13 +1,16 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Route } from 'react-router-dom';
import App from '../src/App';
import MainHeader from '../src/common/MainHeader';
import { identity } from 'ramda';
import appFactory from '../src/App';
describe('<App />', () => {
let wrapper;
const MainHeader = () => '';
beforeEach(() => {
const App = appFactory(MainHeader, identity, identity, identity);
wrapper = shallow(<App />);
});
afterEach(() => wrapper.unmount());

View File

@@ -1,17 +1,18 @@
import { shallow } from 'enzyme';
import React from 'react';
import { NavLink } from 'react-router-dom';
import AsideMenu from '../../src/common/AsideMenu';
import asideMenuCreator from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => {
let wrapped;
const DeleteServerButton = () => '';
beforeEach(() => {
const AsideMenu = asideMenuCreator(DeleteServerButton);
wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />);
});
afterEach(() => {
wrapped.unmount();
});
afterEach(() => wrapped.unmount());
it('contains links to different sections', () => {
const links = wrapped.find(NavLink);
@@ -22,6 +23,6 @@ describe('<AsideMenu />', () => {
});
it('contains a button to delete server', () => {
expect(wrapped.find('DeleteServerButton')).toHaveLength(1);
expect(wrapped.find(DeleteServerButton)).toHaveLength(1);
});
});

View File

@@ -2,7 +2,7 @@ import { shallow } from 'enzyme';
import { values } from 'ramda';
import React from 'react';
import * as sinon from 'sinon';
import { HomeComponent } from '../../src/common/Home';
import Home from '../../src/common/Home';
describe('<Home />', () => {
let wrapped;
@@ -15,7 +15,7 @@ describe('<Home />', () => {
const createComponent = (props) => {
const actualProps = { ...defaultProps, ...props };
wrapped = shallow(<HomeComponent {...actualProps} />);
wrapped = shallow(<Home {...actualProps} />);
return wrapped;
};

View File

@@ -2,11 +2,11 @@ import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import sinon from 'sinon';
import { CreateServerComponent } from '../../src/servers/CreateServer';
import ImportServersBtn from '../../src/servers/helpers/ImportServersBtn';
import createServerConstruct from '../../src/servers/CreateServer';
describe('<CreateServer />', () => {
let wrapper;
const ImportServersBtn = () => '';
const createServerMock = sinon.fake();
const historyMock = {
push: sinon.fake(),
@@ -16,12 +16,10 @@ describe('<CreateServer />', () => {
createServerMock.resetHistory();
historyMock.push.resetHistory();
const CreateServer = createServerConstruct(ImportServersBtn);
wrapper = shallow(
<CreateServerComponent
createServer={createServerMock}
resetSelectedServer={identity}
history={historyMock}
/>
<CreateServer createServer={createServerMock} resetSelectedServer={identity} history={historyMock} />
);
});
afterEach(() => wrapper.unmount());

View File

@@ -1,12 +1,14 @@
import React from 'react';
import { shallow } from 'enzyme';
import DeleteServerButton from '../../src/servers/DeleteServerButton';
import deleteServerButtonConstruct from '../../src/servers/DeleteServerButton';
import DeleteServerModal from '../../src/servers/DeleteServerModal';
describe('<DeleteServerButton />', () => {
let wrapper;
beforeEach(() => {
const DeleteServerButton = deleteServerButtonConstruct(DeleteServerModal);
wrapper = shallow(<DeleteServerButton server={{}} className="button" />);
});
afterEach(() => wrapper.unmount());

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { DeleteServerModalComponent } from '../../src/servers/DeleteServerModal';
import DeleteServerModal from '../../src/servers/DeleteServerModal';
describe('<DeleteServerModal />', () => {
let wrapper;
@@ -17,7 +17,7 @@ describe('<DeleteServerModal />', () => {
historyMock.push.resetHistory();
wrapper = shallow(
<DeleteServerModalComponent
<DeleteServerModal
server={{ name: serverName }}
toggle={toggleMock}
isOpen={true}

View File

@@ -2,10 +2,11 @@ import { identity, values } from 'ramda';
import React from 'react';
import { shallow } from 'enzyme';
import { DropdownItem, DropdownToggle } from 'reactstrap';
import { ServersDropdownComponent } from '../../src/servers/ServersDropdown';
import serversDropdownCreator from '../../src/servers/ServersDropdown';
describe('<ServersDropdown />', () => {
let wrapped;
let ServersDropdown;
const servers = {
'1a': { name: 'foo', id: 1 },
'2b': { name: 'bar', id: 2 },
@@ -13,7 +14,8 @@ describe('<ServersDropdown />', () => {
};
beforeEach(() => {
wrapped = shallow(<ServersDropdownComponent servers={servers} listServers={identity} />);
ServersDropdown = serversDropdownCreator({});
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} />);
});
afterEach(() => wrapped.unmount());
@@ -31,7 +33,7 @@ describe('<ServersDropdown />', () => {
});
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);
expect(item).toHaveLength(1);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import { UncontrolledTooltip } from 'reactstrap';
import { ImportServersBtnComponent } from '../../../src/servers/helpers/ImportServersBtn';
import importServersBtnConstruct from '../../../src/servers/helpers/ImportServersBtn';
describe('<ImportServersBtn />', () => {
let wrapper;
@@ -21,13 +21,10 @@ describe('<ImportServersBtn />', () => {
serversImporterMock.importServersFromFile.resetHistory();
fileRef.current.click.resetHistory();
const ImportServersBtn = importServersBtnConstruct(serversImporterMock);
wrapper = shallow(
<ImportServersBtnComponent
createServers={createServersMock}
serversImporter={serversImporterMock}
fileRef={fileRef}
onImport={onImportMock}
/>
<ImportServersBtn createServers={createServersMock} fileRef={fileRef} onImport={onImportMock} />
);
});
afterEach(() => wrapper.unmount());

View File

@@ -1,8 +1,8 @@
import * as sinon from 'sinon';
import reducer, {
_selectServer,
RESET_SELECTED_SERVER,
selectServer,
resetSelectedServer,
RESET_SELECTED_SERVER,
SELECT_SERVER,
} from '../../../src/servers/reducers/selectedServer';
import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams';
@@ -29,9 +29,6 @@ describe('selectedServerReducer', () => {
});
describe('selectServer', () => {
const ShlinkApiClientMock = {
setConfig: sinon.spy(),
};
const serverId = 'abc123';
const selectedServer = {
id: serverId,
@@ -41,7 +38,6 @@ describe('selectedServerReducer', () => {
};
afterEach(() => {
ShlinkApiClientMock.setConfig.resetHistory();
ServersServiceMock.findServerById.resetHistory();
});
@@ -49,7 +45,7 @@ describe('selectedServerReducer', () => {
const dispatch = sinon.spy();
const expectedDispatchCalls = 2;
_selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(dispatch);
selectServer(ServersServiceMock)(serverId)(dispatch);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.firstCall.calledWith({ type: RESET_SHORT_URL_PARAMS })).toEqual(true);
@@ -60,9 +56,8 @@ describe('selectedServerReducer', () => {
});
it('invokes dependencies', () => {
_selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(() => {});
selectServer(ServersServiceMock)(serverId)(() => {});
expect(ShlinkApiClientMock.setConfig.callCount).toEqual(1);
expect(ServersServiceMock.findServerById.callCount).toEqual(1);
});
});

View File

@@ -1,10 +1,10 @@
import * as sinon from 'sinon';
import { values } from 'ramda';
import reducer, {
_createServer,
_deleteServer,
_listServers,
_createServers,
createServer,
deleteServer,
listServers,
createServers,
FETCH_SERVERS,
} from '../../../src/servers/reducers/server';
@@ -38,7 +38,7 @@ describe('serverReducer', () => {
describe('listServers', () => {
it('fetches servers and returns them as part of the action', () => {
const result = _listServers(ServersServiceMock);
const result = listServers(ServersServiceMock)();
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);
@@ -51,7 +51,7 @@ describe('serverReducer', () => {
describe('createServer', () => {
it('adds new server and then fetches servers again', () => {
const serverToCreate = { id: 'abc123' };
const result = _createServer(ServersServiceMock, serverToCreate);
const result = createServer(ServersServiceMock)(serverToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);
@@ -65,7 +65,7 @@ describe('serverReducer', () => {
describe('deleteServer', () => {
it('deletes a server and then fetches servers again', () => {
const serverToDelete = { id: 'abc123' };
const result = _deleteServer(ServersServiceMock, serverToDelete);
const result = deleteServer(ServersServiceMock)(serverToDelete);
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);
@@ -79,7 +79,7 @@ describe('serverReducer', () => {
describe('createServer', () => {
it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(servers);
const result = _createServers(ServersServiceMock, serversToCreate);
const result = createServers(ServersServiceMock)(serversToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1);

View File

@@ -1,5 +1,5 @@
import sinon from 'sinon';
import { ServersExporter } from '../../../src/servers/services/ServersExporter';
import ServersExporter from '../../../src/servers/services/ServersExporter';
describe('ServersExporter', () => {
const createLinkMock = () => ({

View File

@@ -1,5 +1,5 @@
import sinon from 'sinon';
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
import ServersImporter from '../../../src/servers/services/ServersImporter';
describe('ServersImporter', () => {
const servers = [{ name: 'foo' }, { name: 'bar' }];

View File

@@ -1,6 +1,6 @@
import sinon from 'sinon';
import { last } from 'ramda';
import { ServersService } from '../../../src/servers/services/ServersService';
import ServersService from '../../../src/servers/services/ServersService';
describe('ServersService', () => {
const servers = {

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { shallow } from 'enzyme';
import moment from 'moment';
import * as sinon from 'sinon';
import { identity } from 'ramda';
import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl';
import DateInput from '../../src/utils/DateInput';
describe('<CreateShortUrl />', () => {
let wrapper;
const TagsSelector = () => '';
const shortUrlCreationResult = {
loading: false,
};
const createShortUrl = sinon.spy();
beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector);
wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />
);
});
afterEach(() => {
wrapper.unmount();
createShortUrl.resetHistory();
});
it('saves short URL with data set in form controls', (done) => {
const validSince = moment('2017-01-01');
const validUntil = moment('2017-01-06');
const urlInput = wrapper.find('.form-control-lg');
const tagsInput = wrapper.find(TagsSelector);
const customSlugInput = wrapper.find('#customSlug');
const maxVisitsInput = wrapper.find('#maxVisits');
const dateInputs = wrapper.find(DateInput);
const validSinceInput = dateInputs.at(0);
const validUntilInput = dateInputs.at(1);
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
maxVisitsInput.simulate('change', { target: { value: '20' } });
validSinceInput.simulate('change', validSince);
validUntilInput.simulate('change', validUntil);
setImmediate(() => {
const form = wrapper.find('form');
form.simulate('submit', { preventDefault: identity });
expect(createShortUrl.callCount).toEqual(1);
expect(createShortUrl.getCall(0).args).toEqual(
[
{
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
},
]
);
done();
});
});
});

View File

@@ -6,11 +6,7 @@ import Paginator from '../../src/short-urls/Paginator';
describe('<Paginator />', () => {
let wrapper;
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
afterEach(() => wrapper && wrapper.unmount());
it('renders nothing if the number of pages is below 2', () => {
wrapper = shallow(<Paginator serverId="abc123" />);

View File

@@ -1,13 +1,14 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import { SearchBarComponent } from '../../src/short-urls/SearchBar';
import searchBarCreator from '../../src/short-urls/SearchBar';
import SearchField from '../../src/utils/SearchField';
import Tag from '../../src/tags/helpers/Tag';
describe('<SearchBar />', () => {
let wrapper;
const listShortUrlsMock = sinon.spy();
const SearchBar = searchBarCreator({});
afterEach(() => {
listShortUrlsMock.resetHistory();
@@ -18,13 +19,13 @@ describe('<SearchBar />', () => {
});
it('renders a SearchField', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} />);
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
expect(wrapper.find(SearchField)).toHaveLength(1);
});
it('renders no tags when the list of tags is empty', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} />);
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
expect(wrapper.find(Tag)).toHaveLength(0);
});
@@ -32,13 +33,13 @@ describe('<SearchBar />', () => {
it('renders the proper amount of tags', () => {
const tags = [ 'foo', 'bar', 'baz' ];
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{ tags }} />);
wrapper = shallow(<SearchBar shortUrlsListParams={{ tags }} />);
expect(wrapper.find(Tag)).toHaveLength(tags.length);
});
it('updates short URLs list when search field changes', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
const searchField = wrapper.find(SearchField);
expect(listShortUrlsMock.callCount).toEqual(0);
@@ -48,7 +49,7 @@ describe('<SearchBar />', () => {
it('updates short URLs list when a tag is removed', () => {
wrapper = shallow(
<SearchBarComponent shortUrlsListParams={{ tags: [ 'foo' ] }} listShortUrls={listShortUrlsMock} />
<SearchBar shortUrlsListParams={{ tags: [ 'foo' ] }} listShortUrls={listShortUrlsMock} />
);
const tag = wrapper.find(Tag).first();

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import shortUrlsCreator from '../../src/short-urls/ShortUrls';
import Paginator from '../../src/short-urls/Paginator';
describe('<ShortUrls />', () => {
let wrapper;
const SearchBar = () => '';
const ShortUrlsList = () => '';
beforeEach(() => {
const params = {
serverId: '1',
page: '1',
};
const ShortUrls = shortUrlsCreator(SearchBar, ShortUrlsList);
wrapper = shallow(<ShortUrls match={{ params }} shortUrlsList={{ data: [] }} />);
});
afterEach(() => wrapper.unmount());
it('wraps a SearchBar, ShortUrlsList as Paginator', () => {
expect(wrapper.find(SearchBar)).toHaveLength(1);
expect(wrapper.find(ShortUrlsList)).toHaveLength(1);
expect(wrapper.find(Paginator)).toHaveLength(1);
});
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap';
import CreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
describe('<CreateShortUrlResult />', () => {
let wrapper;
const createWrapper = (result, error = false) => {
wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it('renders an error when error is true', () => {
const wrapper = createWrapper({}, true);
const errorCard = wrapper.find('.bg-danger');
expect(errorCard).toHaveLength(1);
expect(errorCard.html()).toContain('An error occurred while creating the URL :(');
});
it('renders nothing when no result is provided', () => {
const wrapper = createWrapper();
expect(wrapper.html()).toBeNull();
});
it('renders a result message when result is provided', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
expect(wrapper.html()).toContain('<b>Great!</b> The short URL is <b>https://doma.in/abc123</b>');
expect(wrapper.find(CopyToClipboard)).toHaveLength(1);
expect(wrapper.find(Tooltip)).toHaveLength(1);
});
it('Shows tooltip when copy to clipboard button is clicked', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
const copyBtn = wrapper.find(CopyToClipboard);
expect(wrapper.state('showCopyTooltip')).toEqual(false);
copyBtn.simulate('copy');
expect(wrapper.state('showCopyTooltip')).toEqual(true);
});
});

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import * as sinon from 'sinon';
import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal';
describe('<DeleteShortUrlModal />', () => {
let wrapper;
const shortUrl = {
tags: [],
shortCode: 'abc123',
originalUrl: 'https://long-domain.com/foo/bar',
};
const deleteShortUrl = sinon.fake.returns(Promise.resolve());
const createWrapper = (shortUrlDeletion) => {
wrapper = shallow(
<DeleteShortUrlModal
isOpen
shortUrl={shortUrl}
shortUrlDeletion={shortUrlDeletion}
toggle={identity}
deleteShortUrl={deleteShortUrl}
resetDeleteShortUrl={identity}
shortUrlDeleted={identity}
/>
);
return wrapper;
};
afterEach(() => {
wrapper && wrapper.unmount();
deleteShortUrl.resetHistory();
});
it('shows threshold error message when threshold error occurs', () => {
const wrapper = createWrapper({
loading: false,
error: true,
shortCode: 'abc123',
errorData: { error: 'INVALID_SHORTCODE_DELETION' },
});
const warning = wrapper.find('.bg-warning');
expect(warning).toHaveLength(1);
expect(warning.html()).toContain('This short URL has received too many visits and therefore, it cannot be deleted');
});
it('shows generic error when non-threshold error occurs', () => {
const wrapper = createWrapper({
loading: false,
error: true,
shortCode: 'abc123',
errorData: { error: 'OTHER_ERROR' },
});
const error = wrapper.find('.bg-danger');
expect(error).toHaveLength(1);
expect(error.html()).toContain('Something went wrong while deleting the URL :(');
});
it('disables submit button when loading', () => {
const wrapper = createWrapper({
loading: true,
error: false,
shortCode: 'abc123',
errorData: {},
});
const submit = wrapper.find('.btn-danger');
expect(submit).toHaveLength(1);
expect(submit.prop('disabled')).toEqual(true);
expect(submit.html()).toContain('Deleting...');
});
it('enables submit button when proper short code is provided', (done) => {
const shortCode = 'abc123';
const wrapper = createWrapper({
loading: false,
error: false,
shortCode,
errorData: {},
});
const input = wrapper.find('.form-control');
input.simulate('change', { target: { value: shortCode } });
setImmediate(() => {
const submit = wrapper.find('.btn-danger');
expect(submit.prop('disabled')).toEqual(false);
done();
});
});
it('tries to delete short URL when form is submit', (done) => {
const shortCode = 'abc123';
const wrapper = createWrapper({
loading: false,
error: false,
shortCode,
errorData: {},
});
const input = wrapper.find('.form-control');
input.simulate('change', { target: { value: shortCode } });
setImmediate(() => {
const form = wrapper.find('form');
expect(deleteShortUrl.callCount).toEqual(0);
form.simulate('submit', { preventDefault: identity });
expect(deleteShortUrl.callCount).toEqual(1);
done();
});
});
});

View File

@@ -0,0 +1,151 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import { Modal } from 'reactstrap';
import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
describe('<EditTagsModal />', () => {
let wrapper;
const shortCode = 'abc123';
const TagsSelector = () => '';
const editShortUrlTags = sinon.fake.resolves();
const shortUrlTagsEdited = sinon.fake();
const resetShortUrlsTags = sinon.fake();
const toggle = sinon.fake();
const createWrapper = (shortUrlTags) => {
const EditTagsModal = createEditTagsModal(TagsSelector);
wrapper = shallow(
<EditTagsModal
isOpen={true}
url={''}
shortUrl={{
tags: [],
shortCode,
originalUrl: 'https://long-domain.com/foo/bar',
}}
shortUrlTags={shortUrlTags}
toggle={toggle}
editShortUrlTags={editShortUrlTags}
shortUrlTagsEdited={shortUrlTagsEdited}
resetShortUrlsTags={resetShortUrlsTags}
/>
);
return wrapper;
};
afterEach(() => {
wrapper && wrapper.unmount();
editShortUrlTags.resetHistory();
shortUrlTagsEdited.resetHistory();
resetShortUrlsTags.resetHistory();
toggle.resetHistory();
});
it('resets tags when component is mounted', () => {
createWrapper({
shortCode,
tags: [],
saving: false,
error: false,
});
expect(resetShortUrlsTags.callCount).toEqual(1);
});
it('renders tags selector and save button when loaded', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: false,
error: false,
});
const saveBtn = wrapper.find('.btn-primary');
expect(wrapper.find(TagsSelector)).toHaveLength(1);
expect(saveBtn.prop('disabled')).toBe(false);
expect(saveBtn.text()).toEqual('Save tags');
});
it('disables save button when saving is in progress', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
const saveBtn = wrapper.find('.btn-primary');
expect(saveBtn.prop('disabled')).toBe(true);
expect(saveBtn.text()).toEqual('Saving tags...');
});
it('saves tags when save button is clicked', (done) => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
const saveBtn = wrapper.find('.btn-primary');
saveBtn.simulate('click');
expect(editShortUrlTags.callCount).toEqual(1);
expect(editShortUrlTags.getCall(0).args).toEqual([ shortCode, []]);
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => {
expect(toggle.callCount).toEqual(1);
done();
});
});
it('does not notify tags have been edited when window is closed without saving', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: false,
error: false,
});
const modal = wrapper.find(Modal);
modal.simulate('closed');
expect(shortUrlTagsEdited.callCount).toEqual(0);
});
it('notifies tags have been edited when window is closed after saving', (done) => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
const saveBtn = wrapper.find('.btn-primary');
const modal = wrapper.find(Modal);
saveBtn.simulate('click');
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => {
modal.simulate('closed');
expect(shortUrlTagsEdited.callCount).toEqual(1);
expect(shortUrlTagsEdited.getCall(0).args).toEqual([ shortCode, []]);
done();
});
});
it('toggles modal when cancel button is clicked', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
const cancelBtn = wrapper.find('.btn-link');
cancelBtn.simulate('click');
expect(toggle.callCount).toEqual(1);
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
import ExternalLink from '../../../src/utils/ExternalLink';
describe('<PreviewModal />', () => {
let wrapper;
const url = 'https://doma.in/abc123';
beforeEach(() => {
wrapper = shallow(<PreviewModal url={url} />);
});
afterEach(() => wrapper.unmount());
it('shows an external link to the URL', () => {
const externalLink = wrapper.find(ExternalLink);
expect(externalLink).toHaveLength(1);
expect(externalLink.prop('href')).toEqual(url);
});
it('displays an image with the preview of the URL', () => {
const img = wrapper.find('img');
expect(img).toHaveLength(1);
expect(img.prop('src')).toEqual(`${url}/preview`);
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
import ExternalLink from '../../../src/utils/ExternalLink';
describe('<QrCodeModal />', () => {
let wrapper;
const url = 'https://doma.in/abc123';
beforeEach(() => {
wrapper = shallow(<QrCodeModal url={url} />);
});
afterEach(() => wrapper.unmount());
it('shows an external link to the URL', () => {
const externalLink = wrapper.find(ExternalLink);
expect(externalLink).toHaveLength(1);
expect(externalLink.prop('href')).toEqual(url);
});
it('displays an image with the QR code of the URL', () => {
const img = wrapper.find('img');
expect(img).toHaveLength(1);
expect(img.prop('src')).toEqual(`${url}/qr-code`);
});
});

View File

@@ -0,0 +1,95 @@
import * as sinon from 'sinon';
import reducer, {
CREATE_SHORT_URL_START,
CREATE_SHORT_URL_ERROR,
CREATE_SHORT_URL,
RESET_CREATE_SHORT_URL,
createShortUrl,
resetCreateShortUrl,
} from '../../../src/short-urls/reducers/shortUrlCreation';
describe('shortUrlCreationReducer', () => {
describe('reducer', () => {
it('returns loading on CREATE_SHORT_URL_START', () => {
expect(reducer({}, { type: CREATE_SHORT_URL_START })).toEqual({
saving: true,
error: false,
});
});
it('returns error on CREATE_SHORT_URL_ERROR', () => {
expect(reducer({}, { type: CREATE_SHORT_URL_ERROR })).toEqual({
saving: false,
error: true,
});
});
it('returns result on CREATE_SHORT_URL', () => {
expect(reducer({}, { type: CREATE_SHORT_URL, result: 'foo' })).toEqual({
saving: false,
error: false,
result: 'foo',
});
});
it('returns default state on RESET_CREATE_SHORT_URL', () => {
expect(reducer({}, { type: RESET_CREATE_SHORT_URL })).toEqual({
result: null,
saving: false,
error: false,
});
});
it('returns provided state on unknown action', () =>
expect(reducer({}, { type: 'unknown' })).toEqual({}));
});
describe('resetCreateShortUrl', () => {
it('returns proper action', () =>
expect(resetCreateShortUrl()).toEqual({ type: RESET_CREATE_SHORT_URL }));
});
describe('createShortUrl', () => {
const createApiClientMock = (result) => ({
createShortUrl: sinon.fake.returns(result),
});
const dispatch = sinon.spy();
const getState = () => ({});
afterEach(() => dispatch.resetHistory());
it('calls API on success', async () => {
const expectedDispatchCalls = 2;
const result = 'foo';
const apiClientMock = createApiClientMock(Promise.resolve(result));
const dispatchable = createShortUrl(() => apiClientMock)({});
await dispatchable(dispatch, getState);
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL, result }]);
});
it('throws on error', async () => {
const expectedDispatchCalls = 2;
const error = 'Error';
const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = createShortUrl(() => apiClientMock)({});
try {
await dispatchable(dispatch, getState);
} catch (e) {
expect(e).toEqual(error);
}
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL_ERROR }]);
});
});
});

View File

@@ -0,0 +1,115 @@
import * as sinon from 'sinon';
import reducer, {
DELETE_SHORT_URL, DELETE_SHORT_URL_ERROR,
DELETE_SHORT_URL_START,
RESET_DELETE_SHORT_URL,
SHORT_URL_DELETED,
resetDeleteShortUrl,
shortUrlDeleted,
deleteShortUrl,
} from '../../../src/short-urls/reducers/shortUrlDeletion';
describe('shortUrlDeletionReducer', () => {
describe('reducer', () => {
it('returns loading on DELETE_SHORT_URL_START', () =>
expect(reducer(undefined, { type: DELETE_SHORT_URL_START })).toEqual({
shortCode: '',
loading: true,
error: false,
errorData: {},
}));
it('returns default on RESET_DELETE_SHORT_URL', () =>
expect(reducer(undefined, { type: RESET_DELETE_SHORT_URL })).toEqual({
shortCode: '',
loading: false,
error: false,
errorData: {},
}));
it('returns shortCode on DELETE_SHORT_URL', () =>
expect(reducer(undefined, { type: DELETE_SHORT_URL, shortCode: 'foo' })).toEqual({
shortCode: 'foo',
loading: false,
error: false,
errorData: {},
}));
it('returns errorData on DELETE_SHORT_URL_ERROR', () => {
const errorData = { foo: 'bar' };
expect(reducer(undefined, { type: DELETE_SHORT_URL_ERROR, errorData })).toEqual({
shortCode: '',
loading: false,
error: true,
errorData,
});
});
it('returns provided state as is on unknown action', () => {
const state = { foo: 'bar' };
expect(reducer(state, { type: 'unknown' })).toEqual(state);
});
});
describe('resetDeleteShortUrl', () => {
it('returns expected action', () =>
expect(resetDeleteShortUrl()).toEqual({ type: RESET_DELETE_SHORT_URL }));
});
describe('shortUrlDeleted', () => {
it('returns expected action', () =>
expect(shortUrlDeleted('abc123')).toEqual({ type: SHORT_URL_DELETED, shortCode: 'abc123' }));
});
describe('deleteShortUrl', () => {
const dispatch = sinon.spy();
const getState = sinon.fake.returns({ selectedServer: {} });
afterEach(() => {
dispatch.resetHistory();
getState.resetHistory();
});
it('dispatches proper actions if API client request succeeds', async () => {
const apiClientMock = {
deleteShortUrl: sinon.fake.resolves(''),
};
const shortCode = 'abc123';
const expectedDispatchCalls = 2;
await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_SHORT_URL, shortCode }]);
expect(apiClientMock.deleteShortUrl.callCount).toEqual(1);
expect(apiClientMock.deleteShortUrl.getCall(0).args).toEqual([ shortCode ]);
});
it('dispatches proper actions if API client request fails', async () => {
const data = { foo: 'bar' };
const error = { response: { data } };
const apiClientMock = {
deleteShortUrl: sinon.fake.returns(Promise.reject(error)),
};
const shortCode = 'abc123';
const expectedDispatchCalls = 2;
try {
await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
} catch (e) {
expect(e).toEqual(error);
}
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_SHORT_URL_ERROR, errorData: data }]);
expect(apiClientMock.deleteShortUrl.callCount).toEqual(1);
expect(apiClientMock.deleteShortUrl.getCall(0).args).toEqual([ shortCode ]);
});
});
});

View File

@@ -0,0 +1,120 @@
import * as sinon from 'sinon';
import reducer, {
LIST_SHORT_URLS,
LIST_SHORT_URLS_ERROR,
LIST_SHORT_URLS_START,
listShortUrls,
} from '../../../src/short-urls/reducers/shortUrlsList';
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
describe('shortUrlsListReducer', () => {
describe('reducer', () => {
it('returns loading on LIST_SHORT_URLS_START', () =>
expect(reducer(undefined, { type: LIST_SHORT_URLS_START })).toEqual({
shortUrls: {},
loading: true,
error: false,
}));
it('returns short URLs on LIST_SHORT_URLS', () =>
expect(reducer(undefined, { type: LIST_SHORT_URLS, shortUrls: { data: [], paginator: {} } })).toEqual({
shortUrls: { data: [], paginator: {} },
loading: false,
error: false,
}));
it('returns error on LIST_SHORT_URLS_ERROR', () =>
expect(reducer(undefined, { type: LIST_SHORT_URLS_ERROR })).toEqual({
shortUrls: {},
loading: false,
error: true,
}));
it('Updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => {
const shortCode = 'abc123';
const tags = [ 'foo', 'bar', 'baz' ];
const state = {
shortUrls: {
data: [
{ shortCode, tags: [] },
{ shortCode: 'foo', tags: [] },
],
},
};
expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags })).toEqual({
shortUrls: {
data: [
{ shortCode, tags },
{ shortCode: 'foo', tags: [] },
],
},
});
});
it('Removes matching URL on SHORT_URL_DELETED', () => {
const shortCode = 'abc123';
const state = {
shortUrls: {
data: [
{ shortCode },
{ shortCode: 'foo' },
],
},
};
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode })).toEqual({
shortUrls: {
data: [{ shortCode: 'foo' }],
},
});
});
it('returns provided state as is on unknown action', () => {
const state = { foo: 'bar' };
expect(reducer(state, { type: 'unknown' })).toEqual(state);
});
});
describe('listShortUrls', () => {
const dispatch = sinon.spy();
const getState = sinon.fake.returns({ selectedServer: {} });
afterEach(() => {
dispatch.resetHistory();
getState.resetHistory();
});
it('dispatches proper actions if API client request succeeds', async () => {
const apiClientMock = {
listShortUrls: sinon.fake.resolves([]),
};
const expectedDispatchCalls = 2;
await listShortUrls(() => apiClientMock)()(dispatch, getState);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: LIST_SHORT_URLS_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: LIST_SHORT_URLS, shortUrls: [], params: {} }]);
expect(apiClientMock.listShortUrls.callCount).toEqual(1);
});
it('dispatches proper actions if API client request fails', async () => {
const apiClientMock = {
listShortUrls: sinon.fake.rejects(),
};
const expectedDispatchCalls = 2;
await listShortUrls(() => apiClientMock)()(dispatch, getState);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: LIST_SHORT_URLS_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: LIST_SHORT_URLS_ERROR, params: {} }]);
expect(apiClientMock.listShortUrls.callCount).toEqual(1);
});
});
});

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