Compare commits

..

28 Commits

Author SHA1 Message Date
Alejandro Celaya
359b16e700 Merge pull request #168 from acelaya-forks/feature/default-servers-issue
Feature/default servers issue
2019-10-21 19:52:31 +02:00
Alejandro Celaya
0237af771d Fixed outdated comment 2019-10-21 19:45:35 +02:00
Alejandro Celaya
86cce5b205 Updated changelog 2019-10-21 19:39:59 +02:00
Alejandro Celaya
fc7a2e0c6d Ensured response from servers.json has been parsed to a json array 2019-10-21 19:38:32 +02:00
Alejandro Celaya
f74d135922 Ensured default servers is validated as JSON and ignored otherwise 2019-10-21 19:26:09 +02:00
Alejandro Celaya
66124370a6 Added json extension to the list of known static files that have to fall back to 404 on nginx 2019-10-21 18:49:47 +02:00
Alejandro Celaya
e9fc2bb73a Merge pull request #166 from acelaya-forks/feature/fix-create-short-url
Ensured server version is properly parsed to avoid errors due to inva…
2019-10-18 17:48:02 +02:00
Alejandro Celaya
12f6b94ece Ensured server version is properly parsed to avoid errors due to invalid semver 2019-10-18 17:39:38 +02:00
Alejandro Celaya
d9a8243d36 Merge pull request #163 from acelaya-forks/feature/update-deps
Feature/update deps
2019-10-05 20:09:22 +02:00
Alejandro Celaya
232c54885e Updated node version in which builds are run 2019-10-05 19:58:27 +02:00
Alejandro Celaya
42c43f6c78 Added v2.2 to changelog 2019-10-05 19:54:10 +02:00
Alejandro Celaya
9d2494834c Fixed timing issue when navigating to another server 2019-10-05 19:51:50 +02:00
Alejandro Celaya
a7613435ea Fixed test throwing unhandled promise 2019-10-05 19:31:47 +02:00
Alejandro Celaya
c9df044e1a Updated docker image versions 2019-10-05 19:26:06 +02:00
Alejandro Celaya
5a37787042 Fixed warnings in tests 2019-10-05 19:13:57 +02:00
Alejandro Celaya
923cc3ba01 Updated dev dependencies 2019-10-05 19:08:50 +02:00
Alejandro Celaya
8fcf72f564 Updated production dependencies to latest versions 2019-10-05 18:50:49 +02:00
Alejandro Celaya
a7f7666ccd Merge pull request #162 from acelaya-forks/feature/domain
Feature/domain
2019-10-05 11:15:09 +02:00
Alejandro Celaya
c181948afe Updated changelog 2019-10-05 11:05:03 +02:00
Alejandro Celaya
ce9ecd7b93 Defined custom function to compare versions which defines the operator in the middle 2019-10-05 11:03:17 +02:00
Alejandro Celaya
354d19af1b Disabled domain component for Shlink versions not supporting it 2019-10-05 10:54:58 +02:00
Alejandro Celaya
6d996baf5d Added tests for new logics 2019-10-05 10:40:32 +02:00
Alejandro Celaya
4120d09220 Loaded version of selected server and created component to filter content based on that version 2019-10-05 10:20:33 +02:00
Alejandro Celaya
67a23bfe33 Added domain input to create short url form 2019-10-05 09:02:02 +02:00
Alejandro Celaya
08b710930d Merge pull request #161 from acelaya-forks/feature/further-issue-template-improvements
Solved inconsistencies in issue templates due to copy-pasting from ot…
2019-09-29 09:46:57 +02:00
Alejandro Celaya
7ec3b332ed Solved inconsistencies in issue templates due to copy-pasting from other project 2019-09-29 09:46:19 +02:00
Alejandro Celaya
722eb060f0 Merge pull request #160 from acelaya-forks/feature/improved-issue-templates
Added improved issue templates and funding config
2019-09-29 09:42:45 +02:00
Alejandro Celaya
ce740aed68 Added improved issue templates and funding config 2019-09-29 09:36:57 +02:00
35 changed files with 7258 additions and 6795 deletions

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
language: node_js
node_js:
- "10.16.3"
- "12.11.0"
cache:
directories:

View File

@@ -4,6 +4,75 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.2.2 - 2019-10-21
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#167](https://github.com/shlinkio/shlink-web-client/issues/167) Fixed `/servers.json` path not being ignored when returning something other than an array.
## 2.2.1 - 2019-10-18
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#165](https://github.com/shlinkio/shlink-web-client/issues/165) Fixed error thrown when opening "create" page while using a Shlink version which does not return a valid SemVer version (like `latest` docker image, or any development instance).
## 2.2.0 - 2019-10-05
#### Added
* [#144](https://github.com/shlinkio/shlink-web-client/issues/144) Added domain input to create domain page.
#### Changed
* [#140](https://github.com/shlinkio/shlink-web-client/issues/140) Updated project dependencies.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 2.1.1 - 2019-09-22
#### Added

View File

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

View File

@@ -5,7 +5,7 @@ server {
index index.html;
# When requesting static paths with extension, try them, and return a 404 if not found
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
try_files $uri $uri/ =404;
}

View File

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

13342
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import Swipeable from 'react-swipeable';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';

View File

@@ -1,6 +1,5 @@
import { isEmpty, values } from 'ramda';
import React from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from './prop-types';
@@ -11,11 +10,20 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
renderServers = () => {
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
const servers = values(list);
const { push } = this.props.history;
const loadServer = (id) => {
selectServer(id)
.then(() => push(`/server/${id}/list-short-urls/1`))
.catch(() => {});
};
if (loading) {
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
@@ -28,15 +36,7 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
return (
<React.Fragment>
{servers.map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
active={selectedServer && selectedServer.id === id}
// FIXME This should be implicit
onClick={() => selectServer(id)}
>
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
{name}
</DropdownItem>
))}

View File

@@ -1,23 +1,36 @@
import { createAction, handleActions } from 'redux-actions';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionIsValidSemVer } from '../../utils/utils';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
const initialState = null;
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = ({ findServerById }) => (serverId) => (dispatch) => {
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = findServerById(serverId);
const { health } = await buildShlinkApiClient(selectedServer);
const version = await health()
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
.catch(() => MIN_FALLBACK_VERSION);
dispatch({
type: SELECT_SERVER,
selectedServer,
selectedServer: {
...selectedServer,
version,
},
});
};

View File

@@ -30,10 +30,20 @@ export const listServers = ({ listServers, createServers }, { get }) => () => as
return;
}
// If local list is empty, try to fetch it remotely and calculate IDs for every server
// If local list is empty, try to fetch it remotely (making sure it's an array) and calculate IDs for every server
const getDataAsArrayWithIds = pipe(
prop('data'),
(value) => {
if (!Array.isArray(value)) {
throw new Error('Value is not an array');
}
return value;
},
map(assocId),
);
const remoteList = await get(`${homepage}/servers.json`)
.then(prop('data'))
.then(map(assocId))
.then(getDataAsArrayWithIds)
.catch(() => []);
createServers(remoteList);

View File

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

View File

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

View File

@@ -20,11 +20,11 @@ const renderInfoModal = (isOpen, toggle) => (
<ul>
<li>
When only the long URL is provided: The most recent match will be returned, or a new short URL will be created
if none is found
if none is found.
</li>
<li>
When long URL and custom slug are provided: Same as in previous case, but it will try to match the short URL
using both the long URL and the slug.
When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match
the short URL using both the long URL and the slug, the long URL and the domain, or the three of them.
<br />
If the slug is being used by another long URL, an error will be returned.
</li>
@@ -33,9 +33,6 @@ const renderInfoModal = (isOpen, toggle) => (
all provided data. If any of them does not match, a new short URL will be created
</li>
</ul>
<blockquote className="use-existing-if-found-info-icon__modal-quote">
<b>Important:</b> This feature will be ignored while using a Shlink version older than v1.16.0.
</blockquote>
</ModalBody>
</Modal>
);

View File

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

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

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

View File

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

View File

@@ -13,8 +13,10 @@ const getSelectedServerFromState = async (getState) => {
return selectedServer;
};
const buildShlinkApiClient = (axios) => async (getState) => {
const { url, apiKey } = await getSelectedServerFromState(getState);
const buildShlinkApiClient = (axios) => async (getStateOrSelectedServer) => {
const { url, apiKey } = typeof getStateOrSelectedServer === 'function'
? await getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer;
const clientKey = `${url}_${apiKey}`;
if (!apiClients[clientKey]) {

View File

@@ -4,6 +4,7 @@ import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda';
import { useState } from 'react';
import { compare } from 'compare-versions';
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
@@ -53,3 +54,17 @@ export const useToggle = (initialValue = false) => {
};
export const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
export const compareVersions = (firstVersion, operator, secondVersion) => compare(
firstVersion,
secondVersion,
operator
);
export const versionIsValidSemVer = (version) => {
try {
return compareVersions(version, '=', version);
} catch (e) {
return false;
}
};

View File

@@ -15,15 +15,18 @@ describe('<ServersDropdown />', () => {
},
loading: false,
};
const history = {
push: jest.fn(),
};
beforeEach(() => {
ServersDropdown = serversDropdownCreator({});
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} />);
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} history={history} />);
});
afterEach(() => wrapped.unmount());
it('contains the list of servers', () =>
expect(wrapped.find(DropdownItem).filter('[to]')).toHaveLength(values(servers.list).length));
it('contains the list of servers, the divider and the export button', () =>
expect(wrapped.find(DropdownItem)).toHaveLength(values(servers.list).length + 2));
it('contains a toggle with proper title', () =>
expect(wrapped.find(DropdownToggle)).toHaveLength(1));
@@ -36,7 +39,9 @@ describe('<ServersDropdown />', () => {
});
it('shows a message when no servers exist yet', () => {
wrapped = shallow(<ServersDropdown servers={{ loading: false, list: {} }} listServers={identity} />);
wrapped = shallow(
<ServersDropdown servers={{ loading: false, list: {} }} listServers={identity} history={history} />
);
const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1);
@@ -45,7 +50,9 @@ describe('<ServersDropdown />', () => {
});
it('shows a message when loading', () => {
wrapped = shallow(<ServersDropdown servers={{ loading: true, list: {} }} listServers={identity} />);
wrapped = shallow(
<ServersDropdown servers={{ loading: true, list: {} }} listServers={identity} history={history} />
);
const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1);

View File

@@ -1,8 +1,11 @@
import each from 'jest-each';
import reducer, {
selectServer,
resetSelectedServer,
RESET_SELECTED_SERVER,
SELECT_SERVER,
MAX_FALLBACK_VERSION,
MIN_FALLBACK_VERSION,
} from '../../../src/servers/reducers/selectedServer';
import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams';
@@ -29,28 +32,55 @@ describe('selectedServerReducer', () => {
const selectedServer = {
id: serverId,
};
const version = '1.19.0';
const ServersServiceMock = {
findServerById: jest.fn(() => selectedServer),
};
const apiClientMock = {
health: jest.fn(),
};
const buildApiClient = jest.fn().mockResolvedValue(apiClientMock);
const dispatch = jest.fn();
afterEach(() => {
ServersServiceMock.findServerById.mockClear();
});
afterEach(jest.clearAllMocks);
it('dispatches proper actions', () => {
const dispatch = jest.fn();
each([
[ version, version ],
[ 'latest', MAX_FALLBACK_VERSION ],
[ '%invalid_semver%', MIN_FALLBACK_VERSION ],
]).it('dispatches proper actions', async (serverVersion, expectedVersion) => {
const expectedSelectedServer = {
...selectedServer,
version: expectedVersion,
};
selectServer(ServersServiceMock)(serverId)(dispatch);
apiClientMock.health.mockResolvedValue({ version: serverVersion });
await selectServer(ServersServiceMock, buildApiClient)(serverId)(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SHORT_URL_PARAMS });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
});
it('invokes dependencies', () => {
selectServer(ServersServiceMock)(serverId)(() => {});
it('invokes dependencies', async () => {
await selectServer(ServersServiceMock, buildApiClient)(serverId)(() => {});
expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1);
expect(buildApiClient).toHaveBeenCalledTimes(1);
});
it('falls back to min version when health endpoint fails', async () => {
const expectedSelectedServer = {
...selectedServer,
version: MIN_FALLBACK_VERSION,
};
apiClientMock.health.mockRejectedValue({});
await selectServer(ServersServiceMock, buildApiClient)(serverId)(dispatch);
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
});
});
});

View File

@@ -1,4 +1,5 @@
import { values } from 'ramda';
import each from 'jest-each';
import reducer, {
createServer,
deleteServer,
@@ -20,27 +21,18 @@ describe('serverReducer', () => {
createServers: jest.fn(),
};
afterEach(jest.clearAllMocks);
describe('reducer', () => {
it('returns servers when action is FETCH_SERVERS', () =>
expect(reducer({}, { type: FETCH_SERVERS, list })).toEqual({ loading: false, list }));
});
describe('action creators', () => {
beforeEach(() => {
ServersServiceMock.listServers.mockClear();
ServersServiceMock.createServer.mockReset();
ServersServiceMock.deleteServer.mockReset();
ServersServiceMock.createServers.mockReset();
});
describe('listServers', () => {
const axios = { get: jest.fn().mockResolvedValue({ data: [] }) };
const axios = { get: jest.fn() };
const dispatch = jest.fn();
beforeEach(() => {
axios.get.mockClear();
dispatch.mockReset();
});
const NoListServersServiceMock = { ...ServersServiceMock, listServers: jest.fn(() => ({})) };
it('fetches servers from local storage when found', async () => {
await listServers(ServersServiceMock, axios)()(dispatch);
@@ -55,14 +47,49 @@ describe('serverReducer', () => {
expect(axios.get).not.toHaveBeenCalled();
});
it('tries to fetch servers from remote when not found locally', async () => {
const NoListServersServiceMock = { ...ServersServiceMock, listServers: jest.fn(() => ({})) };
each([
[
Promise.resolve({
data: [
{
id: '111',
name: 'acel.me from servers.json',
url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
},
{
id: '222',
name: 'Local from servers.json',
url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
},
],
}),
{
111: {
id: '111',
name: 'acel.me from servers.json',
url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
},
222: {
id: '222',
name: 'Local from servers.json',
url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
},
},
],
[ Promise.resolve('<html></html>'), {}],
[ Promise.reject({}), {}],
]).it('tries to fetch servers from remote when not found locally', async (mockedValue, expectedList) => {
axios.get.mockReturnValue(mockedValue);
await listServers(NoListServersServiceMock, axios)()(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: FETCH_SERVERS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: {} });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: expectedList });
expect(NoListServersServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(NoListServersServiceMock.createServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.deleteServer).not.toHaveBeenCalled();

View File

@@ -32,6 +32,7 @@ describe('<CreateShortUrl />', () => {
const urlInput = wrapper.find('.form-control-lg');
const tagsInput = wrapper.find(TagsSelector);
const customSlugInput = wrapper.find('#customSlug');
const domain = wrapper.find('#domain');
const maxVisitsInput = wrapper.find('#maxVisits');
const dateInputs = wrapper.find(DateInput);
const validSinceInput = dateInputs.at(0);
@@ -40,6 +41,7 @@ describe('<CreateShortUrl />', () => {
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
domain.simulate('change', { target: { value: 'example.com' } });
maxVisitsInput.simulate('change', { target: { value: '20' } });
validSinceInput.simulate('change', validSince);
validUntilInput.simulate('change', validUntil);
@@ -53,6 +55,7 @@ describe('<CreateShortUrl />', () => {
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
domain: 'example.com',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',

View File

@@ -46,15 +46,17 @@ describe('shortUrlTagsReducer', () => {
});
});
describe('resetShortUrlsTags', () =>
it('creates expected action', () => expect(resetShortUrlsTags()).toEqual({ type: RESET_EDIT_SHORT_URL_TAGS })));
describe('resetShortUrlsTags', () => {
it('creates expected action', () => expect(resetShortUrlsTags()).toEqual({ type: RESET_EDIT_SHORT_URL_TAGS }));
});
describe('shortUrlTagsEdited', () =>
describe('shortUrlTagsEdited', () => {
it('creates expected action', () => expect(shortUrlTagsEdited(shortCode, tags)).toEqual({
tags,
shortCode,
type: SHORT_URL_TAGS_EDITED,
})));
}));
});
describe('editShortUrlTags', () => {
const updateShortUrlTags = jest.fn();

View File

@@ -70,8 +70,9 @@ describe('tagsListReducer', () => {
});
});
describe('filterTags', () =>
it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: FILTER_TAGS, searchTerm: 'foo' })));
describe('filterTags', () => {
it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: FILTER_TAGS, searchTerm: 'foo' }));
});
describe('listTags', () => {
const dispatch = jest.fn();

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { mount } from 'enzyme';
import ForVersion from '../../src/utils/ForVersion';
describe('<ForVersion />', () => {
let wrapped;
const renderComponent = (minVersion, currentServerVersion) => {
wrapped = mount(
<ForVersion minVersion={minVersion} currentServerVersion={currentServerVersion}>
<span>Hello</span>
</ForVersion>
);
return wrapped;
};
afterEach(() => wrapped && wrapped.unmount());
it('does not render children when current version is empty', () => {
const wrapped = renderComponent('1', '');
expect(wrapped.html()).toBeNull();
});
it('does not render children when current version is lower than min version', () => {
const wrapped = renderComponent('2.0.0', '1.8.3');
expect(wrapped.html()).toBeNull();
});
it('renders children when current version is equal min version', () => {
const wrapped = renderComponent('2.0.0', '2.0.0');
expect(wrapped.html()).toContain('<span>Hello</span>');
});
it('renders children when current version is higher than min version', () => {
const wrapped = renderComponent('2.0.0', '2.1.0');
expect(wrapped.html()).toContain('<span>Hello</span>');
});
});

View File

@@ -165,4 +165,20 @@ describe('ShlinkApiClient', () => {
}));
});
});
describe('health', () => {
it('returns health data', async () => {
const expectedData = {
status: 'pass',
version: '1.19.0',
};
const axiosSpy = jest.fn(createAxiosMock({ data: expectedData }));
const { health } = new ShlinkApiClient(axiosSpy);
const result = await health();
expect(axiosSpy).toHaveBeenCalled();
expect(result).toEqual(expectedData);
});
});
});

View File

@@ -1,4 +1,5 @@
import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder';
import { buildShlinkBaseUrl } from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClientBuilder', () => {
const createBuilder = () => {
@@ -33,4 +34,13 @@ describe('ShlinkApiClientBuilder', () => {
expect(firstApiClient).toBe(thirdApiClient);
expect(secondApiClient).toBe(thirdApiClient);
});
it('does not fetch from state when provided param is already selected server', async () => {
const url = 'url';
const apiKey = 'apiKey';
const apiClient = await buildShlinkApiClient({})({ url, apiKey });
expect(apiClient._baseUrl).toEqual(buildShlinkBaseUrl(url));
expect(apiClient._apiKey).toEqual(apiKey);
});
});