Compare commits

...

38 Commits

Author SHA1 Message Date
Alejandro Celaya
da54a72b3e Merge pull request #206 from acelaya-forks/feature/match-domain
Feature/match domain
2020-02-08 11:04:58 +01:00
Alejandro Celaya
86c155d8d1 Updated changelog 2020-02-08 10:47:44 +01:00
Alejandro Celaya
666d2d3065 Ensured domain is dispatched when modifying a short URL somehow 2020-02-08 10:46:11 +01:00
Alejandro Celaya
01e69fb6ca Merge pull request #204 from acelaya-forks/feature/multi-domain-fixes
Feature/multi domain fixes
2020-02-08 10:24:49 +01:00
Alejandro Celaya
30e5253acd Simplified instructions removing redundant vars 2020-02-08 10:07:34 +01:00
Alejandro Celaya
c67ce3918b Removed redundant function call 2020-02-08 10:03:24 +01:00
Alejandro Celaya
58077f2d86 Updated changelog 2020-02-08 09:59:13 +01:00
Alejandro Celaya
098c94bccf Ensured domain is passed when deleting a short URL on a specific domain 2020-02-08 09:57:18 +01:00
Alejandro Celaya
861a3c068f Ensured domain is passed when editing meta for a short URL on a specific domain 2020-02-08 09:52:30 +01:00
Alejandro Celaya
3b95e8ebc0 Ensured domain is passed when editing tags for a short URL on a specific domain 2020-02-08 09:48:35 +01:00
Alejandro Celaya
170e427530 Ensured domain is passed when loading detail for a short URL on a specific domain 2020-02-08 09:38:19 +01:00
Alejandro Celaya
707c9f4ce6 Created VisitStatsLink test 2020-02-08 09:22:17 +01:00
Alejandro Celaya
dc672bf0f0 Ensured domain is passed when loading visits for a short URL on a specific domain 2020-02-08 09:07:55 +01:00
Alejandro Celaya
c682737505 Standardized date-picker selected day color 2020-02-02 09:30:41 +01:00
Alejandro Celaya
46fa3d4345 Merge pull request #201 from acelaya-forks/feature/document-routing-fallback
Updated documentation
2020-01-31 20:51:17 +01:00
Alejandro Celaya
9b7bc4b495 Updated documentation 2020-01-31 20:37:50 +01:00
Alejandro Celaya
4385061499 Merge pull request #200 from acelaya-forks/feature/refactor-edit-tags-modal
Feature/refactor edit tags modal
2020-01-31 20:22:04 +01:00
Alejandro Celaya
e17498e68b Updated changelog 2020-01-31 20:13:18 +01:00
Alejandro Celaya
3e298f010b Simplified DeleteShortUrlModal component and shortUrlDeletion reducer 2020-01-31 20:12:22 +01:00
Alejandro Celaya
30117bd121 Simplified EditTagsModal component and shortUrlTags reducer 2020-01-31 20:06:28 +01:00
Alejandro Celaya
93f33b6218 Fixed some tests after not injecting a component 2020-01-31 20:04:03 +01:00
Alejandro Celaya
535d08a607 Merge pull request #197 from MartinH0/master
Add htaccess to redirect if not found to index
2020-01-31 16:21:30 +01:00
MartinH0
6ac3a49db2 Updated nginx.conf (optimization for future)
1. changed location from "~" (case sensitive!) to "~*" (case insensitive!) to also match uppercase static assets. (http://nginx.org/en/docs/http/ngx_http_core_module.html#location)
2. added regex "jpe?g" to match "jpg" and "jpeg" in one command.
2020-01-31 01:36:17 +01:00
MartinH0
c16f760d79 Update .htaccess
1. removed $ (dollar sign from line 14
2. changed line 8 from ".*" to "(.*)"
2020-01-31 01:29:41 +01:00
MartinH0
965c2b243f Update .htaccess
1. added more comments.
2. added NC Tag for making all the static assets case insensetive ("jpg" now matches "jpg" and "JPG" and so on)
3. transformed "jpe|jpeg" into "jpe?g" as its regex for the same, but shorter
4. changed line 8 from "+" to "*" to match everything, also zero times the wildcard
2020-01-31 01:27:13 +01:00
MartinH0
703addddb9 updated htaccess
deleted second json (just needed once)
2020-01-30 21:05:20 +01:00
MartinH0
ab6dff5c31 Updates htaccess
return 404 error if static assets does not exist
2020-01-30 20:51:23 +01:00
MartinH0
2ef330c62b Updated htaccess 2020-01-30 20:49:46 +01:00
MartinH0
72e71aff40 Updated htaccess to meet required functions.
If a file gets called it will be redirected to index.html

But not if it the requested File does contain a dot (and with this does have a file extension.

If you call:
links.domain.de/notexistingfile.jpg 
It will trigger 404

If you call:
links.domain.de/server/[CODE-CODE-CODE]/list-short-urls/1
It will redirect the call to index.html
2020-01-30 19:06:50 +01:00
MartinH0
cefd6ec752 Add htaccess to redirect if not found to index
If (file not found or directory not found)
then > redirect to index.html
2020-01-30 18:51:38 +01:00
MartinH0
aec3de18aa Deleted .htaccess at wrong directory
Sorry fucked it up, will correct it.
2020-01-30 18:51:32 +01:00
MartinH0
97620cb583 Add htaccess to redirect if not found to index
If file not fount or directory not found redirect to index.html
2020-01-30 18:36:02 +01:00
Alejandro Celaya
cf4e8190a4 Merge pull request #195 from acelaya-forks/feature/server-version-wrapper
Feature/server version wrapper
2020-01-28 19:55:24 +01:00
Alejandro Celaya
8af7436f13 Updated changelog 2020-01-28 19:47:41 +01:00
Alejandro Celaya
c53520ae56 Moved logic to dynamically render components based on server version to a separated component 2020-01-28 19:46:36 +01:00
Alejandro Celaya
3adcaef455 Merge pull request #194 from acelaya-forks/feature/fix-set-empty-max-visits
Fixed maxVisits being set to 0 when trying to reset it
2020-01-28 18:51:18 +01:00
Alejandro Celaya
43cd9722a9 Updated project to node 12.14.1 2020-01-28 18:40:33 +01:00
Alejandro Celaya
f3154e770e Fixed maxVisits being set to 0 when trying to reset it 2020-01-28 18:36:23 +01:00
49 changed files with 467 additions and 356 deletions

View File

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

View File

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

View File

@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.3.1 - 2020-02-08
#### Added
* *Nothing*
#### Changed
* [#191](https://github.com/shlinkio/shlink-web-client/issues/191) Created `ForServerVersion` helper component which dynamically renders children if current server conditions are met.
* [#189](https://github.com/shlinkio/shlink-web-client/issues/189) Simplified short url tags and short url deletion components and reducers, by removing redundant actions.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#193](https://github.com/shlinkio/shlink-web-client/issues/193) Fixed `maxVisits` being set to 0 when trying to reset it from having a value to `null`.
* [#196](https://github.com/shlinkio/shlink-web-client/issues/196) Included apache `.htaccess` file which takes care of falling back to index.html when reloading the page on a client-side handled route.
* [#179](https://github.com/shlinkio/shlink-web-client/issues/179) Ensured domain is provided to Shlink server when editing, deleting or fetching short URLs which do not belong to default domain.
* [#202](https://github.com/shlinkio/shlink-web-client/issues/202) Fixed domain not passed when dispatching actions that affect a single short URL (edit tags, edit meta and delete), which cased the list not to be properly updated.
## 2.3.0 - 2020-01-19 ## 2.3.0 - 2020-01-19
#### Added #### Added

View File

@@ -1,4 +1,4 @@
FROM node:12.11.1-alpine as node FROM node:12.14.1-alpine as node
COPY . /shlink-web-client COPY . /shlink-web-client
RUN cd /shlink-web-client && npm install && npm run build RUN cd /shlink-web-client && npm install && npm run build

View File

@@ -14,23 +14,30 @@ A ReactJS-based progressive web application for [Shlink](https://shlink.io).
There are three ways in which you can use this application. There are three ways in which you can use this application.
* The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>. ### From app.shlink.io
The application runs 100% in the browser, so you can safely access any shlink instance from there. The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
* Self hosting the application yourself. The application runs 100% in the browser, so you can safely access any shlink instance from there.
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`). ### Docker image
The package contains static files only, so just put it in a folder and serve it with the web server of your choice. If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath). It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
* Using the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/) ### Self-hosted
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the `shlinkio/shlink-web-client` image and do it. If you want to self-host it yourself, 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`).
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80. The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
**Considerations**:
* Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
* The app has a client-side router that handles dynamic paths. Because of that, you need to configure your web server to fall-back to the `index.html` file when requested files do not exist.
* If you use Apache, you are covered, since the project includes an `.htaccess` file which already does this.
* If you use nginx, you can [see how it's done](config/docker/nginx.conf) for the docker image and do the same.
## Pre-configuring servers ## Pre-configuring servers

View File

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

View File

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

16
public/.htaccess Normal file
View File

@@ -0,0 +1,16 @@
RewriteEngine on
RewriteBase /
# do not do anything for already existing files
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule (.*) - [L]
# if request is no valid file NOR directory
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# if static asset do not do anything
RewriteRule (.*)(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) - [NC,L,R=404]
# everything else should be redirected to /index.html so it can be routed by it
RewriteRule (.*) /index.html [L]

View File

@@ -20,7 +20,7 @@ const mapActionService = (map, actionName) => ({
// Wrap actual action service in a function so that it is lazily created the first time it is called // Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: lazyService(container, actionName), [actionName]: lazyService(container, actionName),
}); });
const connect = (propsFromState, actionServiceNames) => const connect = (propsFromState, actionServiceNames = []) =>
reduxConnect( reduxConnect(
propsFromState ? pick(propsFromState) : null, propsFromState ? pick(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {}) actionServiceNames.reduce(mapActionService, {})

View File

@@ -63,3 +63,11 @@ body,
.indivisible { .indivisible {
white-space: nowrap; white-space: nowrap;
} }
.react-datepicker__day--keyboard-selected {
background-color: $mainColor;
&:hover {
background-color: darken($mainColor, 12%);
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compareVersions } from '../../utils/utils';
import { serverType } from '../prop-types';
const propTypes = {
minVersion: PropTypes.string,
maxVersion: PropTypes.string,
selectedServer: serverType,
children: PropTypes.node.isRequired,
};
const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children }) => {
if (!selectedServer) {
return null;
}
const { version } = selectedServer;
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion);
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
if (!matchesMinVersion || !matchesMaxVersion) {
return null;
}
return <React.Fragment>{children}</React.Fragment>;
};
ForServerVersion.propTypes = propTypes;
export default ForServerVersion;

View File

@@ -5,4 +5,5 @@ export const serverType = PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
url: PropTypes.string, url: PropTypes.string,
apiKey: PropTypes.string, apiKey: PropTypes.string,
version: PropTypes.string,
}); });

View File

@@ -6,6 +6,7 @@ import DeleteServerButton from '../DeleteServerButton';
import ImportServersBtn from '../helpers/ImportServersBtn'; import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer'; import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server'; import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
import ForServerVersion from '../helpers/ForServerVersion';
import ServersImporter from './ServersImporter'; import ServersImporter from './ServersImporter';
import ServersService from './ServersService'; import ServersService from './ServersService';
import ServersExporter from './ServersExporter'; import ServersExporter from './ServersExporter';
@@ -28,6 +29,9 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter'); bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ])); bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
// Services // Services
bottle.constant('csvjson', csvjson); bottle.constant('csvjson', csvjson);
bottle.service('ServersImporter', ServersImporter, 'csvjson'); bottle.service('ServersImporter', ServersImporter, 'csvjson');

View File

@@ -6,7 +6,6 @@ import { Collapse, FormGroup, Input } from 'reactstrap';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput'; import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox'; import Checkbox from '../utils/Checkbox';
import ForVersion from '../utils/ForVersion';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils'; import { compareVersions } from '../utils/utils';
import { createShortUrlResultType } from './reducers/shortUrlCreation'; import { createShortUrlResultType } from './reducers/shortUrlCreation';
@@ -15,7 +14,11 @@ import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format(); const formatDate = (date) => isNil(date) ? date : date.format();
const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShortUrl extends React.Component { const CreateShortUrl = (
TagsSelector,
CreateShortUrlResult,
ForServerVersion
) => class CreateShortUrl extends React.Component {
static propTypes = { static propTypes = {
createShortUrl: PropTypes.func, createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType, shortUrlCreationResult: createShortUrlResultType,
@@ -116,7 +119,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
</div> </div>
</div> </div>
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}> <ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right"> <div className="mb-4 text-right">
<Checkbox <Checkbox
className="mr-2" className="mr-2"
@@ -127,7 +130,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
</Checkbox> </Checkbox>
<UseExistingIfFoundInfoIcon /> <UseExistingIfFoundInfoIcon />
</div> </div>
</ForVersion> </ForServerVersion>
</Collapse> </Collapse>
<div> <div>

View File

@@ -7,23 +7,19 @@ import moment from 'moment';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag'; import Tag from '../tags/helpers/Tag';
import DateRangeRow from '../utils/DateRangeRow'; import DateRangeRow from '../utils/DateRangeRow';
import { compareVersions, formatDate } from '../utils/utils'; import { formatDate } from '../utils/utils';
import { serverType } from '../servers/prop-types';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss'; import './SearchBar.scss';
const propTypes = { const propTypes = {
listShortUrls: PropTypes.func, listShortUrls: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType, shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType,
}; };
const dateOrUndefined = (date) => date ? moment(date) : undefined; const dateOrUndefined = (date) => date ? moment(date) : undefined;
const SearchBar = (colorGenerator) => { const SearchBar = (colorGenerator, ForServerVersion) => {
const SearchBar = ({ listShortUrls, shortUrlsListParams, selectedServer }) => { const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
const currentServerVersion = selectedServer ? selectedServer.version : '';
const enableDateFiltering = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.21.0');
const selectedTags = shortUrlsListParams.tags || []; const selectedTags = shortUrlsListParams.tags || [];
const setDate = (dateName) => pipe( const setDate = (dateName) => pipe(
formatDate(), formatDate(),
@@ -38,7 +34,7 @@ const SearchBar = (colorGenerator) => {
} }
/> />
{enableDateFiltering && ( <ForServerVersion minVersion="1.21.0">
<div className="mt-3"> <div className="mt-3">
<DateRangeRow <DateRangeRow
startDate={dateOrUndefined(shortUrlsListParams.startDate)} startDate={dateOrUndefined(shortUrlsListParams.startDate)}
@@ -47,7 +43,7 @@ const SearchBar = (colorGenerator) => {
onEndDateChange={setDate('endDate')} onEndDateChange={setDate('endDate')}
/> />
</div> </div>
)} </ForServerVersion>
{!isEmpty(selectedTags) && ( {!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-3"> <h4 className="search-bar__selected-tag mt-3">

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { identity } from 'ramda'; import { identity, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion'; import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
@@ -15,21 +15,17 @@ export default class DeleteShortUrlModal extends React.Component {
shortUrlDeletion: shortUrlDeletionType, shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func, deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func, resetDeleteShortUrl: PropTypes.func,
shortUrlDeleted: PropTypes.func,
}; };
state = { inputValue: '' }; state = { inputValue: '' };
handleDeleteUrl = (e) => { handleDeleteUrl = (e) => {
e.preventDefault(); e.preventDefault();
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props; const { deleteShortUrl, shortUrl, toggle } = this.props;
const { shortCode } = shortUrl; const { shortCode, domain } = shortUrl;
deleteShortUrl(shortCode) deleteShortUrl(shortCode, domain)
.then(() => { .then(toggle)
shortUrlDeleted(shortCode);
toggle();
})
.catch(identity); .catch(identity);
}; };
@@ -40,16 +36,17 @@ export default class DeleteShortUrlModal extends React.Component {
} }
render() { render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props; const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props;
const { error, errorData } = shortUrlDeletion; const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error); const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED; const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED; const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered> <Modal isOpen={isOpen} toggle={close} centered>
<form onSubmit={this.handleDeleteUrl}> <form onSubmit={this.handleDeleteUrl}>
<ModalHeader toggle={toggle}> <ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span> <span className="text-danger">Delete short URL</span>
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
@@ -77,7 +74,7 @@ export default class DeleteShortUrlModal extends React.Component {
)} )}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button> <button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button <button
type="submit" type="submit"
className="btn btn-danger" className="btn btn-danger"

View File

@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import moment from 'moment'; import moment from 'moment';
import { pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta'; import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput'; import DateInput from '../../utils/DateInput';
@@ -36,8 +36,8 @@ const EditMetaModal = (
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits); const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
const close = pipe(resetShortUrlMeta, toggle); const close = pipe(resetShortUrlMeta, toggle);
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, { const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
maxVisits: maxVisits && parseInt(maxVisits), maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null,
validSince: validSince && formatIsoDate(validSince), validSince: validSince && formatIsoDate(validSince),
validUntil: validUntil && formatIsoDate(validUntil), validUntil: validUntil && formatIsoDate(validUntil),
}).then(close); }).then(close);

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { pipe } from 'ramda';
import { shortUrlTagsType } from '../reducers/shortUrlTags'; import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
@@ -12,36 +13,21 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
shortUrl: shortUrlType.isRequired, shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType, shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func, editShortUrlTags: PropTypes.func,
shortUrlTagsEdited: PropTypes.func,
resetShortUrlsTags: PropTypes.func, resetShortUrlsTags: PropTypes.func,
}; };
saveTags = () => { saveTags = () => {
const { editShortUrlTags, shortUrl, toggle } = this.props; const { editShortUrlTags, shortUrl, toggle } = this.props;
editShortUrlTags(shortUrl.shortCode, this.state.tags) editShortUrlTags(shortUrl.shortCode, shortUrl.domain, this.state.tags)
.then(() => { .then(toggle)
this.tagsSaved = true;
toggle();
})
.catch(() => {}); .catch(() => {});
}; };
refreshShortUrls = () => {
if (!this.tagsSaved) {
return;
}
const { shortUrlTagsEdited, shortUrl, shortUrlTags } = this.props;
const { tags } = shortUrlTags;
shortUrlTagsEdited(shortUrl.shortCode, tags);
};
componentDidMount() { componentDidMount() {
const { resetShortUrlsTags } = this.props; const { resetShortUrlsTags } = this.props;
resetShortUrlsTags(); resetShortUrlsTags();
this.tagsSaved = false;
} }
constructor(props) { constructor(props) {
@@ -50,12 +36,13 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
} }
render() { render() {
const { isOpen, toggle, shortUrl, shortUrlTags } = this.props; const { isOpen, toggle, shortUrl, shortUrlTags, resetShortUrlsTags } = this.props;
const url = shortUrl && (shortUrl.shortUrl || ''); const url = shortUrl && (shortUrl.shortUrl || '');
const close = pipe(resetShortUrlsTags, toggle);
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}> <Modal isOpen={isOpen} toggle={close} centered>
<ModalHeader toggle={toggle}> <ModalHeader toggle={close}>
Edit tags for <ExternalLink href={url} /> Edit tags for <ExternalLink href={url} />
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
@@ -67,7 +54,7 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
)} )}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button> <button className="btn btn-link" onClick={close}>Cancel</button>
<button <button
className="btn btn-primary" className="btn btn-primary"
type="button" type="button"

View File

@@ -3,25 +3,33 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import { shortUrlMetaType } from '../reducers/shortUrlMeta'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
import './ShortUrlVisitsCount.scss'; import './ShortUrlVisitsCount.scss';
import VisitStatsLink from './VisitStatsLink';
const propTypes = { const propTypes = {
visitsCount: PropTypes.number.isRequired, visitsCount: PropTypes.number.isRequired,
meta: shortUrlMetaType, shortUrl: shortUrlType,
selectedServer: serverType,
}; };
const ShortUrlVisitsCount = ({ visitsCount, meta }) => { const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
const maxVisits = meta && meta.maxVisits; const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
<strong>{visitsCount}</strong>
</VisitStatsLink>
);
if (!maxVisits) { if (!maxVisits) {
return <span>{visitsCount}</span>; return visitsLink;
} }
return ( return (
<React.Fragment> <React.Fragment>
<span className="indivisible"> <span className="indivisible">
{visitsCount} {visitsLink}
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control"> <small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
{' '}/ {maxVisits}{' '} {' '}/ {maxVisits}{' '}
<sup> <sup>

View File

@@ -58,7 +58,11 @@ const ShortUrlsRow = (
</td> </td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td> <td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: "> <td className="short-urls-row__cell text-md-right" data-th="Visits: ">
<ShortUrlVisitsCount visitsCount={shortUrl.visitsCount} meta={shortUrl.meta} /> <ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
/>
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--relative"> <td className="short-urls-row__cell short-urls-row__cell--relative">
<small <small

View File

@@ -10,21 +10,20 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isEmpty } from 'ramda';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { compareVersions } from '../../utils/utils';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal'; import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal'; import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss'; import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = ( const ShortUrlsRowMenu = (
DeleteShortUrlModal, DeleteShortUrlModal,
EditTagsModal, EditTagsModal,
EditMetaModal EditMetaModal,
ForServerVersion
) => class ShortUrlsRowMenu extends React.Component { ) => class ShortUrlsRowMenu extends React.Component {
static propTypes = { static propTypes = {
onCopyToClipboard: PropTypes.func, onCopyToClipboard: PropTypes.func,
@@ -45,9 +44,6 @@ const ShortUrlsRowMenu = (
render() { render() {
const { onCopyToClipboard, shortUrl, selectedServer } = this.props; const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const currentServerVersion = selectedServer ? selectedServer.version : '';
const showEditMetaBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.18.0');
const showPreviewBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '<', '2.0.0');
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] })); const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen'); const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewModalOpen'); const togglePreview = toggleModal('isPreviewModalOpen');
@@ -61,7 +57,7 @@ const ShortUrlsRowMenu = (
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp; &nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle> </DropdownToggle>
<DropdownMenu right> <DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}> <DropdownItem tag={VisitStatsLink} selectedServer={selectedServer} shortUrl={shortUrl}>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats <FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem> </DropdownItem>
@@ -70,14 +66,12 @@ const ShortUrlsRowMenu = (
</DropdownItem> </DropdownItem>
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} /> <EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
{showEditMetaBtn && ( <ForServerVersion minVersion="1.18.0">
<React.Fragment> <DropdownItem onClick={toggleMeta}>
<DropdownItem onClick={toggleMeta}> <FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata </DropdownItem>
</DropdownItem> <EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} /> </ForServerVersion>
</React.Fragment>
)}
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}> <DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL <FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
@@ -86,21 +80,21 @@ const ShortUrlsRowMenu = (
<DropdownItem divider /> <DropdownItem divider />
{showPreviewBtn && ( <ForServerVersion maxVersion="1.x">
<React.Fragment> <DropdownItem onClick={togglePreview}>
<DropdownItem onClick={togglePreview}> <FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview </DropdownItem>
</DropdownItem> <PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} /> </ForServerVersion>
</React.Fragment>
)}
<DropdownItem onClick={toggleQrCode}> <DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code <FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem> </DropdownItem>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} /> <QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
{showPreviewBtn && <DropdownItem divider />} <ForServerVersion maxVersion="1.x">
<DropdownItem divider />
</ForServerVersion>
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}> <CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
<DropdownItem> <DropdownItem>

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
const propTypes = {
shortUrl: shortUrlType,
selectedServer: serverType,
children: PropTypes.node.isRequired,
};
const buildVisitsUrl = ({ id }, { shortCode, domain }) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${shortCode}/visits${query}`;
};
const VisitStatsLink = ({ selectedServer, shortUrl, children, ...rest }) => {
if (!selectedServer || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildVisitsUrl(selectedServer, shortUrl)} {...rest}>{children}</Link>;
};
VisitStatsLink.propTypes = propTypes;
export default VisitStatsLink;

View File

@@ -5,9 +5,8 @@ import { apiErrorType } from '../../utils/services/ShlinkApiClient';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START'; 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_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'; export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export const shortUrlDeletionType = PropTypes.shape({ export const shortUrlDeletionType = PropTypes.shape({
@@ -27,18 +26,18 @@ const initialState = {
export default handleActions({ export default handleActions({
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }), [DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }), [DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
[DELETE_SHORT_URL]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }), [SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
[RESET_DELETE_SHORT_URL]: () => initialState, [RESET_DELETE_SHORT_URL]: () => initialState,
}, initialState); }, initialState);
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => { export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START }); dispatch({ type: DELETE_SHORT_URL_START });
const { deleteShortUrl } = await buildShlinkApiClient(getState); const { deleteShortUrl } = await buildShlinkApiClient(getState);
try { try {
await deleteShortUrl(shortCode); await deleteShortUrl(shortCode, domain);
dispatch({ type: DELETE_SHORT_URL, shortCode }); dispatch({ type: SHORT_URL_DELETED, shortCode, domain });
} catch (e) { } catch (e) {
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data }); dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
@@ -47,5 +46,3 @@ export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (di
}; };
export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL); export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL);
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View File

@@ -35,13 +35,13 @@ export default handleActions({
[RESET_EDIT_SHORT_URL_META]: () => initialState, [RESET_EDIT_SHORT_URL_META]: () => initialState,
}, initialState); }, initialState);
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, meta) => async (dispatch, getState) => { export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_META_START }); dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState); const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
try { try {
await updateShortUrlMeta(shortCode, meta); await updateShortUrlMeta(shortCode, domain, meta);
dispatch({ shortCode, meta, type: SHORT_URL_META_EDITED }); dispatch({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
} catch (e) { } catch (e) {
dispatch({ type: EDIT_SHORT_URL_META_ERROR }); dispatch({ type: EDIT_SHORT_URL_META_ERROR });

View File

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START'; export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR'; export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED'; export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export const shortUrlTagsType = PropTypes.shape({ export const shortUrlTagsType = PropTypes.shape({
@@ -26,18 +25,18 @@ const initialState = {
export default handleActions({ export default handleActions({
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }), [EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }), [EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_TAGS]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }), [SHORT_URL_TAGS_EDITED]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState, [RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
}, initialState); }, initialState);
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => { export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START }); dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { updateShortUrlTags } = await buildShlinkApiClient(getState); const { updateShortUrlTags } = await buildShlinkApiClient(getState);
try { try {
const normalizedTags = await updateShortUrlTags(shortCode, tags); const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS }); dispatch({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
} catch (e) { } catch (e) {
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR }); dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
@@ -46,9 +45,3 @@ export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => a
}; };
export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS); export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS);
export const shortUrlTagsEdited = (shortCode, tags) => ({
tags,
shortCode,
type: SHORT_URL_TAGS_EDITED,
});

View File

@@ -1,5 +1,5 @@
import { handleActions } from 'redux-actions'; import { handleActions } from 'redux-actions';
import { assoc, assocPath, propEq, reject } from 'ramda'; import { assoc, assocPath, isNil, reject } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_DELETED } from './shortUrlDeletion';
@@ -18,6 +18,7 @@ export const shortUrlType = PropTypes.shape({
visitsCount: PropTypes.number, visitsCount: PropTypes.number,
meta: shortUrlMetaType, meta: shortUrlMetaType,
tags: PropTypes.arrayOf(PropTypes.string), tags: PropTypes.arrayOf(PropTypes.string),
domain: PropTypes.string,
}); });
const initialState = { const initialState = {
@@ -26,10 +27,18 @@ const initialState = {
error: false, error: false,
}; };
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, [prop]: propValue }) => assocPath( const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
state.shortUrls.data.map( state.shortUrls.data.map(
(shortUrl) => shortUrl.shortCode === shortCode ? assoc(prop, propValue, shortUrl) : shortUrl (shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) ? assoc(prop, propValue, shortUrl) : shortUrl
), ),
state state
); );
@@ -38,9 +47,9 @@ export default handleActions({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }), [LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }), [LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }), [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }),
[SHORT_URL_DELETED]: (state, { shortCode }) => assocPath( [SHORT_URL_DELETED]: (state, { shortCode, domain }) => assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
reject(propEq('shortCode', shortCode), state.shortUrls.data), reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state, state,
), ),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),

View File

@@ -12,8 +12,8 @@ import EditMetaModal from '../helpers/EditMetaModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult'; import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList'; import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion'; import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags'; import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta'; import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
@@ -24,8 +24,8 @@ const provideServices = (bottle, connect) => {
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList) (state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
)); ));
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator'); bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams', 'selectedServer' ], [ 'listShortUrls' ])); bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect( bottle.decorator('ShortUrlsList', connect(
@@ -35,26 +35,27 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal', 'EditMetaModal'); bottle.serviceFactory(
'ShortUrlsRowMenu',
ShortUrlsRowMenu,
'DeleteShortUrlModal',
'EditTagsModal',
'EditMetaModal',
'ForServerVersion'
);
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult'); bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
bottle.decorator( bottle.decorator(
'CreateShortUrl', 'CreateShortUrl',
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]) connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
); );
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect( bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
[ 'shortUrlDeletion' ],
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
));
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector'); bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
bottle.decorator('EditTagsModal', connect( bottle.decorator('EditTagsModal', connect([ 'shortUrlTags' ], [ 'editShortUrlTags', 'resetShortUrlsTags' ]));
[ 'shortUrlTags' ],
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
));
bottle.serviceFactory('EditMetaModal', () => EditMetaModal); bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ])); bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
@@ -62,7 +63,6 @@ const provideServices = (bottle, connect) => {
// Actions // Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags); bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams); bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
@@ -72,7 +72,6 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl); bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta); bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);

View File

@@ -1,19 +0,0 @@
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

@@ -1,5 +1,5 @@
import qs from 'qs'; import qs from 'qs';
import { isEmpty, isNil, pipe, reject } from 'ramda'; import { isEmpty, isNil, reject } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
export const apiErrorType = PropTypes.shape({ export const apiErrorType = PropTypes.shape({
@@ -12,6 +12,7 @@ export const apiErrorType = PropTypes.shape({
}); });
const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : ''; const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = reject(isNil);
export default class ShlinkApiClient { export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) { constructor(axios, baseUrl, apiKey) {
@@ -21,10 +22,8 @@ export default class ShlinkApiClient {
this._apiKey = apiKey || ''; this._apiKey = apiKey || '';
} }
listShortUrls = pipe( listShortUrls = (options = {}) =>
(options = {}) => reject(isNil, options), this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls);
(options) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls)
);
createShortUrl = (options) => { createShortUrl = (options) => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options); const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
@@ -37,20 +36,20 @@ export default class ShlinkApiClient {
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query) this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
.then((resp) => resp.data.visits); .then((resp) => resp.data.visits);
getShortUrl = (shortCode) => getShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET') this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
.then((resp) => resp.data); .then((resp) => resp.data);
deleteShortUrl = (shortCode) => deleteShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE') this._performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => ({})); .then(() => ({}));
updateShortUrlTags = (shortCode, tags) => updateShortUrlTags = (shortCode, domain, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags }) this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then((resp) => resp.data.tags); .then((resp) => resp.data.tags);
updateShortUrlMeta = (shortCode, meta) => updateShortUrlMeta = (shortCode, domain, meta) =>
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', {}, meta) this._performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
.then(() => meta); .then(() => meta);
listTags = () => listTags = () =>
@@ -73,7 +72,7 @@ export default class ShlinkApiClient {
method, method,
url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`, url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`,
headers: { 'X-Api-Key': this._apiKey }, headers: { 'X-Api-Key': this._apiKey },
params: query, params: rejectNilProps(query),
data: body, data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }), paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
}); });

View File

@@ -4,6 +4,7 @@ import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react'; import React from 'react';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import qs from 'qs';
import DateRangeRow from '../utils/DateRangeRow'; import DateRangeRow from '../utils/DateRangeRow';
import MutedMessage from '../utils/MuttedMessage'; import MutedMessage from '../utils/MuttedMessage';
import { formatDate } from '../utils/utils'; import { formatDate } from '../utils/utils';
@@ -21,6 +22,9 @@ const ShortUrlVisits = (
match: PropTypes.shape({ match: PropTypes.shape({
params: PropTypes.object, params: PropTypes.object,
}), }),
location: PropTypes.shape({
search: PropTypes.string,
}),
getShortUrlVisits: PropTypes.func, getShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType, shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func, getShortUrlDetail: PropTypes.func,
@@ -29,24 +33,24 @@ const ShortUrlVisits = (
}; };
state = { startDate: undefined, endDate: undefined }; state = { startDate: undefined, endDate: undefined };
loadVisits = () => { loadVisits = (loadDetail = false) => {
const { match: { params }, getShortUrlVisits } = this.props; const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
const { shortCode } = params; const { shortCode } = params;
const dates = mapObjIndexed(formatDate(), this.state); const { startDate, endDate } = mapObjIndexed(formatDate(), this.state);
const { startDate, endDate } = dates; const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs // While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`; this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
getShortUrlVisits(shortCode, dates); getShortUrlVisits(shortCode, { startDate, endDate, domain });
if (loadDetail) {
getShortUrlDetail(shortCode, domain);
}
}; };
componentDidMount() { componentDidMount() {
const { match: { params }, getShortUrlDetail } = this.props;
const { shortCode } = params;
this.timeWhenMounted = new Date().getTime(); this.timeWhenMounted = new Date().getTime();
this.loadVisits(); this.loadVisits(true);
getShortUrlDetail(shortCode);
} }
componentWillUnmount() { componentWillUnmount() {

View File

@@ -33,7 +33,7 @@ export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
<h2> <h2>
<span className="badge badge-main float-right"> <span className="badge badge-main float-right">
Visits:{' '} Visits:{' '}
<ShortUrlVisitsCount visitsCount={visits.length} meta={shortUrl.meta} /> <ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
</span> </span>
Visit stats for <ExternalLink href={shortLink} /> Visit stats for <ExternalLink href={shortLink} />
</h2> </h2>

View File

@@ -26,13 +26,13 @@ export default handleActions({
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }), [GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
}, initialState); }, initialState);
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => { export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_DETAIL_START }); dispatch({ type: GET_SHORT_URL_DETAIL_START });
const { getShortUrl } = await buildShlinkApiClient(getState); const { getShortUrl } = await buildShlinkApiClient(getState);
try { try {
const shortUrl = await getShortUrl(shortCode); const shortUrl = await getShortUrl(shortCode, domain);
dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL }); dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) { } catch (e) {

View File

@@ -49,7 +49,7 @@ export default handleActions({
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
}, initialState); }, initialState);
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => async (dispatch, getState) => { export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START }); dispatch({ type: GET_SHORT_URL_VISITS_START });
const { getShortUrlVisits } = await buildShlinkApiClient(getState); const { getShortUrlVisits } = await buildShlinkApiClient(getState);
@@ -57,7 +57,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
const loadVisits = async (page = 1) => { const loadVisits = async (page = 1) => {
const { pagination, data } = await getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage }); const { pagination, data } = await getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
// If pagination was not returned, then this is an older shlink version. Just return data // If pagination was not returned, then this is an older shlink version. Just return data
if (!pagination || isLastPage(pagination)) { if (!pagination || isLastPage(pagination)) {
@@ -96,7 +96,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const loadVisitsInParallel = (pages) => const loadVisitsInParallel = (pages) =>
Promise.all(pages.map( Promise.all(pages.map(
(page) => (page) =>
getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage }) getShortUrlVisits(shortCode, { ...query, page, itemsPerPage })
.then(prop('data')) .then(prop('data'))
)).then(flatten); )).then(flatten);

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { mount } from 'enzyme';
import each from 'jest-each';
import ForServerVersion from '../../../src/servers/helpers/ForServerVersion';
describe('<ForServerVersion />', () => {
let wrapped;
const renderComponent = (minVersion, maxVersion, selectedServer) => {
wrapped = mount(
<ForServerVersion minVersion={minVersion} maxVersion={maxVersion} selectedServer={selectedServer}>
<span>Hello</span>
</ForServerVersion>
);
return wrapped;
};
afterEach(() => wrapped && wrapped.unmount());
it('does not render children when current server is empty', () => {
const wrapped = renderComponent('1');
expect(wrapped.html()).toBeNull();
});
each([
[ '2.0.0', undefined, '1.8.3' ],
[ undefined, '1.8.0', '1.8.3' ],
[ '1.7.0', '1.8.0', '1.8.3' ],
]).it('does not render children when current version does not match requirements', (min, max, version) => {
const wrapped = renderComponent(min, max, { version });
expect(wrapped.html()).toBeNull();
});
each([
[ '2.0.0', undefined, '2.8.3' ],
[ '2.0.0', undefined, '2.0.0' ],
[ undefined, '1.8.0', '1.8.0' ],
[ undefined, '1.8.0', '1.7.1' ],
[ '1.7.0', '1.8.0', '1.7.3' ],
]).it('renders children when current version matches requirements', (min, max, version) => {
const wrapped = renderComponent(min, max, { version });
expect(wrapped.html()).toContain('<span>Hello</span>');
});
});

View File

@@ -14,7 +14,7 @@ describe('<CreateShortUrl />', () => {
const createShortUrl = jest.fn(); const createShortUrl = jest.fn();
beforeEach(() => { beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => ''); const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '', () => '');
wrapper = shallow( wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} /> <CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />

View File

@@ -9,7 +9,7 @@ import DateRangeRow from '../../src/utils/DateRangeRow';
describe('<SearchBar />', () => { describe('<SearchBar />', () => {
let wrapper; let wrapper;
const listShortUrlsMock = jest.fn(); const listShortUrlsMock = jest.fn();
const SearchBar = searchBarCreator({}); const SearchBar = searchBarCreator({}, () => '');
afterEach(() => { afterEach(() => {
listShortUrlsMock.mockReset(); listShortUrlsMock.mockReset();
@@ -22,15 +22,10 @@ describe('<SearchBar />', () => {
expect(wrapper.find(SearchField)).toHaveLength(1); expect(wrapper.find(SearchField)).toHaveLength(1);
}); });
each([ it('renders a DateRangeRow', () => {
[ '2.0.0', 1 ], wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
[ '1.21.2', 1 ],
[ '1.21.0', 1 ],
[ '1.20.0', 0 ],
]).it('renders a DateRangeRow when proper version is run', (version, expectedLength) => {
wrapper = shallow(<SearchBar shortUrlsListParams={{}} selectedServer={{ version }} />);
expect(wrapper.find(DateRangeRow)).toHaveLength(expectedLength); expect(wrapper.find(DateRangeRow)).toHaveLength(1);
}); });
it('renders no tags when the list of tags is empty', () => { it('renders no tags when the list of tags is empty', () => {
@@ -69,7 +64,7 @@ describe('<SearchBar />', () => {
each([ 'startDateChange', 'endDateChange' ]).it('updates short URLs list when date range changes', (event) => { each([ 'startDateChange', 'endDateChange' ]).it('updates short URLs list when date range changes', (event) => {
wrapper = shallow( wrapper = shallow(
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} selectedServer={{ version: '2.0.0' }} /> <SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />
); );
const dateRange = wrapper.find(DateRangeRow); const dateRange = wrapper.find(DateRangeRow);

View File

@@ -21,7 +21,6 @@ describe('<DeleteShortUrlModal />', () => {
toggle={identity} toggle={identity}
deleteShortUrl={deleteShortUrl} deleteShortUrl={deleteShortUrl}
resetDeleteShortUrl={identity} resetDeleteShortUrl={identity}
shortUrlDeleted={identity}
/> />
); );

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Modal } from 'reactstrap'; import { Modal } from 'reactstrap';
import each from 'jest-each';
import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal'; import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
describe('<EditTagsModal />', () => { describe('<EditTagsModal />', () => {
@@ -8,10 +9,9 @@ describe('<EditTagsModal />', () => {
const shortCode = 'abc123'; const shortCode = 'abc123';
const TagsSelector = () => ''; const TagsSelector = () => '';
const editShortUrlTags = jest.fn(() => Promise.resolve()); const editShortUrlTags = jest.fn(() => Promise.resolve());
const shortUrlTagsEdited = jest.fn();
const resetShortUrlsTags = jest.fn(); const resetShortUrlsTags = jest.fn();
const toggle = jest.fn(); const toggle = jest.fn();
const createWrapper = (shortUrlTags) => { const createWrapper = (shortUrlTags, domain) => {
const EditTagsModal = createEditTagsModal(TagsSelector); const EditTagsModal = createEditTagsModal(TagsSelector);
wrapper = shallow( wrapper = shallow(
@@ -20,12 +20,12 @@ describe('<EditTagsModal />', () => {
shortUrl={{ shortUrl={{
tags: [], tags: [],
shortCode, shortCode,
domain,
originalUrl: 'https://long-domain.com/foo/bar', originalUrl: 'https://long-domain.com/foo/bar',
}} }}
shortUrlTags={shortUrlTags} shortUrlTags={shortUrlTags}
toggle={toggle} toggle={toggle}
editShortUrlTags={editShortUrlTags} editShortUrlTags={editShortUrlTags}
shortUrlTagsEdited={shortUrlTagsEdited}
resetShortUrlsTags={resetShortUrlsTags} resetShortUrlsTags={resetShortUrlsTags}
/> />
); );
@@ -76,19 +76,19 @@ describe('<EditTagsModal />', () => {
expect(saveBtn.text()).toEqual('Saving tags...'); expect(saveBtn.text()).toEqual('Saving tags...');
}); });
it('saves tags when save button is clicked', (done) => { each([[ undefined ], [ null ], [ 'example.com' ]]).it('saves tags when save button is clicked', (domain, done) => {
const wrapper = createWrapper({ const wrapper = createWrapper({
shortCode, shortCode,
tags: [], tags: [],
saving: true, saving: true,
error: false, error: false,
}); }, domain);
const saveBtn = wrapper.find('.btn-primary'); const saveBtn = wrapper.find('.btn-primary');
saveBtn.simulate('click'); saveBtn.simulate('click');
expect(editShortUrlTags).toHaveBeenCalledTimes(1); expect(editShortUrlTags).toHaveBeenCalledTimes(1);
expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, []); expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, domain, []);
// Wrap this expect in a setImmediate since it is called as a result of an inner promise // Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => { setImmediate(() => {
@@ -107,28 +107,7 @@ describe('<EditTagsModal />', () => {
const modal = wrapper.find(Modal); const modal = wrapper.find(Modal);
modal.simulate('closed'); modal.simulate('closed');
expect(shortUrlTagsEdited).not.toHaveBeenCalled(); expect(editShortUrlTags).not.toHaveBeenCalled();
});
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).toHaveBeenCalledTimes(1);
expect(shortUrlTagsEdited).toHaveBeenCalledWith(shortCode, []);
done();
});
}); });
it('toggles modal when cancel button is clicked', () => { it('toggles modal when cancel button is clicked', () => {

View File

@@ -7,8 +7,8 @@ import ShortUrlVisitsCount from '../../../src/short-urls/helpers/ShortUrlVisitsC
describe('<ShortUrlVisitsCount />', () => { describe('<ShortUrlVisitsCount />', () => {
let wrapper; let wrapper;
const createWrapper = (visitsCount, meta) => { const createWrapper = (visitsCount, shortUrl) => {
wrapper = shallow(<ShortUrlVisitsCount visitsCount={visitsCount} meta={meta} />); wrapper = shallow(<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />);
return wrapper; return wrapper;
}; };
@@ -17,11 +17,11 @@ describe('<ShortUrlVisitsCount />', () => {
each([ undefined, {}]).it('just returns visits when no maxVisits is provided', (meta) => { each([ undefined, {}]).it('just returns visits when no maxVisits is provided', (meta) => {
const visitsCount = 45; const visitsCount = 45;
const wrapper = createWrapper(visitsCount, meta); const wrapper = createWrapper(visitsCount, { meta });
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
expect(wrapper.html()).toEqual(`<span>${visitsCount}</span>`); expect(wrapper.html()).toEqual(`<span><strong>${visitsCount}</strong></span>`);
expect(maxVisitsHelper).toHaveLength(0); expect(maxVisitsHelper).toHaveLength(0);
expect(maxVisitsTooltip).toHaveLength(0); expect(maxVisitsTooltip).toHaveLength(0);
}); });
@@ -30,7 +30,7 @@ describe('<ShortUrlVisitsCount />', () => {
const visitsCount = 45; const visitsCount = 45;
const maxVisits = 500; const maxVisits = 500;
const meta = { maxVisits }; const meta = { maxVisits };
const wrapper = createWrapper(visitsCount, meta); const wrapper = createWrapper(visitsCount, { meta });
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { ButtonDropdown, DropdownItem } from 'reactstrap'; import { ButtonDropdown, DropdownItem } from 'reactstrap';
import each from 'jest-each';
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu'; import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal'; import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal'; import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
@@ -17,12 +16,12 @@ describe('<ShortUrlsRowMenu />', () => {
shortCode: 'abc123', shortCode: 'abc123',
shortUrl: 'https://doma.in/abc123', shortUrl: 'https://doma.in/abc123',
}; };
const createWrapper = (serverVersion = '1.21.1') => { const createWrapper = () => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal); const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal, () => '');
wrapper = shallow( wrapper = shallow(
<ShortUrlsRowMenu <ShortUrlsRowMenu
selectedServer={{ ...selectedServer, version: serverVersion }} selectedServer={selectedServer}
shortUrl={shortUrl} shortUrl={shortUrl}
onCopyToClipboard={onCopyToClipboard} onCopyToClipboard={onCopyToClipboard}
/> />
@@ -46,24 +45,12 @@ describe('<ShortUrlsRowMenu />', () => {
expect(qrCodeModal).toHaveLength(1); expect(qrCodeModal).toHaveLength(1);
}); });
each([ it('renders correct amount of menu items', () => {
[ '1.17.0', 6, 2 ], const wrapper = createWrapper();
[ '1.17.2', 6, 2 ],
[ '1.18.0', 7, 2 ],
[ '1.18.1', 7, 2 ],
[ '1.19.0', 7, 2 ],
[ '1.20.3', 7, 2 ],
[ '1.21.0', 7, 2 ],
[ '1.21.1', 7, 2 ],
[ '2.0.0', 6, 1 ],
[ '2.0.1', 6, 1 ],
[ '2.1.0', 6, 1 ],
]).it('renders correct amount of menu items depending on the version', (version, expectedNonDividerItems, expectedDividerItems) => {
const wrapper = createWrapper(version);
const items = wrapper.find(DropdownItem); const items = wrapper.find(DropdownItem);
expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems); expect(items).toHaveLength(9);
expect(items.find('[divider]')).toHaveLength(expectedDividerItems); expect(items.find('[divider]')).toHaveLength(2);
}); });
describe('toggles state when toggling modal windows', () => { describe('toggles state when toggling modal windows', () => {

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { shallow } from 'enzyme';
import each from 'jest-each';
import { Link } from 'react-router-dom';
import VisitStatsLink from '../../../src/short-urls/helpers/VisitStatsLink';
describe('<VisitStatsLink />', () => {
let wrapper;
afterEach(() => wrapper && wrapper.unmount());
each([
[ undefined, undefined ],
[ null, null ],
[{}, null ],
[{}, undefined ],
[ null, {}],
[ undefined, {}],
]).it('only renders a plan span when either server or short URL are not set', (selectedServer, shortUrl) => {
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
const link = wrapper.find(Link);
expect(link).toHaveLength(0);
expect(wrapper.html()).toEqual('<span>Something</span>');
});
each([
[{ id: '1' }, { shortCode: 'abc123' }, '/server/1/short-code/abc123/visits' ],
[
{ id: '3' },
{ shortCode: 'def456', domain: 'example.com' },
'/server/3/short-code/def456/visits?domain=example.com',
],
]).it('renders link with expected query when', (selectedServer, shortUrl, expectedLink) => {
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
const link = wrapper.find(Link);
const to = link.prop('to');
expect(link).toHaveLength(1);
expect(to).toEqual(expectedLink);
});
});

View File

@@ -1,10 +1,10 @@
import each from 'jest-each';
import reducer, { import reducer, {
DELETE_SHORT_URL, DELETE_SHORT_URL_ERROR, DELETE_SHORT_URL_ERROR,
DELETE_SHORT_URL_START, DELETE_SHORT_URL_START,
RESET_DELETE_SHORT_URL, RESET_DELETE_SHORT_URL,
SHORT_URL_DELETED, SHORT_URL_DELETED,
resetDeleteShortUrl, resetDeleteShortUrl,
shortUrlDeleted,
deleteShortUrl, deleteShortUrl,
} from '../../../src/short-urls/reducers/shortUrlDeletion'; } from '../../../src/short-urls/reducers/shortUrlDeletion';
@@ -26,8 +26,8 @@ describe('shortUrlDeletionReducer', () => {
errorData: {}, errorData: {},
})); }));
it('returns shortCode on DELETE_SHORT_URL', () => it('returns shortCode on SHORT_URL_DELETED', () =>
expect(reducer(undefined, { type: DELETE_SHORT_URL, shortCode: 'foo' })).toEqual({ expect(reducer(undefined, { type: SHORT_URL_DELETED, shortCode: 'foo' })).toEqual({
shortCode: 'foo', shortCode: 'foo',
loading: false, loading: false,
error: false, error: false,
@@ -51,11 +51,6 @@ describe('shortUrlDeletionReducer', () => {
expect(resetDeleteShortUrl()).toEqual({ type: RESET_DELETE_SHORT_URL })); 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', () => { describe('deleteShortUrl', () => {
const dispatch = jest.fn(); const dispatch = jest.fn();
const getState = jest.fn().mockReturnValue({ selectedServer: {} }); const getState = jest.fn().mockReturnValue({ selectedServer: {} });
@@ -65,20 +60,22 @@ describe('shortUrlDeletionReducer', () => {
getState.mockClear(); getState.mockClear();
}); });
it('dispatches proper actions if API client request succeeds', async () => { each(
[[ undefined ], [ null ], [ 'example.com' ]]
).it('dispatches proper actions if API client request succeeds', async (domain) => {
const apiClientMock = { const apiClientMock = {
deleteShortUrl: jest.fn(() => ''), deleteShortUrl: jest.fn(() => ''),
}; };
const shortCode = 'abc123'; const shortCode = 'abc123';
await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState); await deleteShortUrl(() => apiClientMock)(shortCode, domain)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_SHORT_URL_START }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL, shortCode }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_DELETED, shortCode, domain });
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1); expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode); expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode, domain);
}); });
it('dispatches proper actions if API client request fails', async () => { it('dispatches proper actions if API client request fails', async () => {
@@ -100,7 +97,7 @@ describe('shortUrlDeletionReducer', () => {
expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL_ERROR, errorData: data }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL_ERROR, errorData: data });
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1); expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode); expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode, undefined);
}); });
}); });
}); });

View File

@@ -1,4 +1,5 @@
import moment from 'moment'; import moment from 'moment';
import each from 'jest-each';
import reducer, { import reducer, {
EDIT_SHORT_URL_META_START, EDIT_SHORT_URL_META_START,
EDIT_SHORT_URL_META_ERROR, EDIT_SHORT_URL_META_ERROR,
@@ -56,15 +57,15 @@ describe('shortUrlMetaReducer', () => {
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
it('dispatches metadata on success', async () => { each([[ undefined ], [ null ], [ 'example.com' ]]).it('dispatches metadata on success', async (domain) => {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, meta)(dispatch); await editShortUrlMeta(buildShlinkApiClient)(shortCode, domain, meta)(dispatch);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, meta); expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, meta);
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_META_EDITED, meta, shortCode }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_META_EDITED, meta, shortCode, domain });
}); });
it('dispatches error on failure', async () => { it('dispatches error on failure', async () => {
@@ -73,14 +74,14 @@ describe('shortUrlMetaReducer', () => {
updateShortUrlMeta.mockRejectedValue(error); updateShortUrlMeta.mockRejectedValue(error);
try { try {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, meta)(dispatch); await editShortUrlMeta(buildShlinkApiClient)(shortCode, undefined, meta)(dispatch);
} catch (e) { } catch (e) {
expect(e).toBe(error); expect(e).toBe(error);
} }
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, meta); expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, meta);
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR });

View File

@@ -1,12 +1,11 @@
import each from 'jest-each';
import reducer, { import reducer, {
EDIT_SHORT_URL_TAGS,
EDIT_SHORT_URL_TAGS_ERROR, EDIT_SHORT_URL_TAGS_ERROR,
EDIT_SHORT_URL_TAGS_START, EDIT_SHORT_URL_TAGS_START,
RESET_EDIT_SHORT_URL_TAGS, RESET_EDIT_SHORT_URL_TAGS,
resetShortUrlsTags, resetShortUrlsTags,
SHORT_URL_TAGS_EDITED, SHORT_URL_TAGS_EDITED,
editShortUrlTags, editShortUrlTags,
shortUrlTagsEdited,
} from '../../../src/short-urls/reducers/shortUrlTags'; } from '../../../src/short-urls/reducers/shortUrlTags';
describe('shortUrlTagsReducer', () => { describe('shortUrlTagsReducer', () => {
@@ -28,8 +27,8 @@ describe('shortUrlTagsReducer', () => {
}); });
}); });
it('returns provided tags and shortCode on EDIT_SHORT_URL_TAGS', () => { it('returns provided tags and shortCode on SHORT_URL_TAGS_EDITED', () => {
expect(reducer({}, { type: EDIT_SHORT_URL_TAGS, tags, shortCode })).toEqual({ expect(reducer({}, { type: SHORT_URL_TAGS_EDITED, tags, shortCode })).toEqual({
tags, tags,
shortCode, shortCode,
saving: false, saving: false,
@@ -51,14 +50,6 @@ describe('shortUrlTagsReducer', () => {
it('creates expected action', () => expect(resetShortUrlsTags()).toEqual({ type: RESET_EDIT_SHORT_URL_TAGS })); it('creates expected action', () => expect(resetShortUrlsTags()).toEqual({ type: RESET_EDIT_SHORT_URL_TAGS }));
}); });
describe('shortUrlTagsEdited', () => {
it('creates expected action', () => expect(shortUrlTagsEdited(shortCode, tags)).toEqual({
tags,
shortCode,
type: SHORT_URL_TAGS_EDITED,
}));
});
describe('editShortUrlTags', () => { describe('editShortUrlTags', () => {
const updateShortUrlTags = jest.fn(); const updateShortUrlTags = jest.fn();
const buildShlinkApiClient = jest.fn().mockResolvedValue({ updateShortUrlTags }); const buildShlinkApiClient = jest.fn().mockResolvedValue({ updateShortUrlTags });
@@ -70,19 +61,22 @@ describe('shortUrlTagsReducer', () => {
dispatch.mockReset(); dispatch.mockReset();
}); });
it('dispatches normalized tags on success', async () => { each([[ undefined ], [ null ], [ 'example.com' ]]).it('dispatches normalized tags on success', async (domain) => {
const normalizedTags = [ 'bar', 'foo' ]; const normalizedTags = [ 'bar', 'foo' ];
updateShortUrlTags.mockResolvedValue(normalizedTags); updateShortUrlTags.mockResolvedValue(normalizedTags);
await editShortUrlTags(buildShlinkApiClient)(shortCode, tags)(dispatch); await editShortUrlTags(buildShlinkApiClient)(shortCode, domain, tags)(dispatch);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledTimes(1); expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, tags); expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, domain, tags);
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_TAGS, tags: normalizedTags, shortCode }); expect(dispatch).toHaveBeenNthCalledWith(
2,
{ type: SHORT_URL_TAGS_EDITED, tags: normalizedTags, shortCode, domain }
);
}); });
it('dispatches error on failure', async () => { it('dispatches error on failure', async () => {
@@ -91,14 +85,14 @@ describe('shortUrlTagsReducer', () => {
updateShortUrlTags.mockRejectedValue(error); updateShortUrlTags.mockRejectedValue(error);
try { try {
await editShortUrlTags(buildShlinkApiClient)(shortCode, tags)(dispatch); await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(dispatch);
} catch (e) { } catch (e) {
expect(e).toBe(error); expect(e).toBe(error);
} }
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledTimes(1); expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, tags); expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, undefined, tags);
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_TAGS_ERROR }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_TAGS_ERROR });

View File

@@ -38,15 +38,17 @@ describe('shortUrlsListReducer', () => {
shortUrls: { shortUrls: {
data: [ data: [
{ shortCode, tags: [] }, { shortCode, tags: [] },
{ shortCode, tags: [], domain: 'example.com' },
{ shortCode: 'foo', tags: [] }, { shortCode: 'foo', tags: [] },
], ],
}, },
}; };
expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags })).toEqual({ expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags, domain: null })).toEqual({
shortUrls: { shortUrls: {
data: [ data: [
{ shortCode, tags }, { shortCode, tags },
{ shortCode, tags: [], domain: 'example.com' },
{ shortCode: 'foo', tags: [] }, { shortCode: 'foo', tags: [] },
], ],
}, },
@@ -55,6 +57,7 @@ describe('shortUrlsListReducer', () => {
it('Updates meta on matching URL on SHORT_URL_META_EDITED', () => { it('Updates meta on matching URL on SHORT_URL_META_EDITED', () => {
const shortCode = 'abc123'; const shortCode = 'abc123';
const domain = 'example.com';
const meta = { const meta = {
maxVisits: 5, maxVisits: 5,
validSince: '2020-05-05', validSince: '2020-05-05',
@@ -62,16 +65,18 @@ describe('shortUrlsListReducer', () => {
const state = { const state = {
shortUrls: { shortUrls: {
data: [ data: [
{ shortCode, meta: { maxVisits: 10 } }, { shortCode, meta: { maxVisits: 10 }, domain },
{ shortCode, meta: { maxVisits: 50 } },
{ shortCode: 'foo', meta: null }, { shortCode: 'foo', meta: null },
], ],
}, },
}; };
expect(reducer(state, { type: SHORT_URL_META_EDITED, shortCode, meta })).toEqual({ expect(reducer(state, { type: SHORT_URL_META_EDITED, shortCode, meta, domain })).toEqual({
shortUrls: { shortUrls: {
data: [ data: [
{ shortCode, meta }, { shortCode, meta, domain: 'example.com' },
{ shortCode, meta: { maxVisits: 50 } },
{ shortCode: 'foo', meta: null }, { shortCode: 'foo', meta: null },
], ],
}, },
@@ -84,6 +89,7 @@ describe('shortUrlsListReducer', () => {
shortUrls: { shortUrls: {
data: [ data: [
{ shortCode }, { shortCode },
{ shortCode, domain: 'example.com' },
{ shortCode: 'foo' }, { shortCode: 'foo' },
], ],
}, },
@@ -91,7 +97,7 @@ describe('shortUrlsListReducer', () => {
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode })).toEqual({ expect(reducer(state, { type: SHORT_URL_DELETED, shortCode })).toEqual({
shortUrls: { shortUrls: {
data: [{ shortCode: 'foo' }], data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
}, },
}); });
}); });

View File

@@ -1,43 +0,0 @@
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

@@ -1,8 +1,14 @@
import each from 'jest-each';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClient', () => { describe('ShlinkApiClient', () => {
const createAxiosMock = (data) => () => Promise.resolve(data); const createAxiosMock = (data) => () => Promise.resolve(data);
const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data)); const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data));
const shortCodesWithDomainCombinations = [
[ 'abc123', null ],
[ 'abc123', undefined ],
[ 'abc123', 'example.com' ],
];
describe('listShortUrls', () => { describe('listShortUrls', () => {
it('properly returns short URLs list', async () => { it('properly returns short URLs list', async () => {
@@ -67,43 +73,45 @@ describe('ShlinkApiClient', () => {
}); });
describe('getShortUrl', () => { describe('getShortUrl', () => {
it('properly returns short URL', async () => { each(shortCodesWithDomainCombinations).it('properly returns short URL', async (shortCode, domain) => {
const expectedShortUrl = { foo: 'bar' }; const expectedShortUrl = { foo: 'bar' };
const axiosSpy = jest.fn(createAxiosMock({ const axiosSpy = jest.fn(createAxiosMock({
data: expectedShortUrl, data: expectedShortUrl,
})); }));
const { getShortUrl } = new ShlinkApiClient(axiosSpy); const { getShortUrl } = new ShlinkApiClient(axiosSpy);
const result = await getShortUrl('abc123'); const result = await getShortUrl(shortCode, domain);
expect(expectedShortUrl).toEqual(result); expect(expectedShortUrl).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123', url: `/short-urls/${shortCode}`,
method: 'GET', method: 'GET',
params: domain ? { domain } : {},
})); }));
}); });
}); });
describe('updateShortUrlTags', () => { describe('updateShortUrlTags', () => {
it('properly updates short URL tags', async () => { each(shortCodesWithDomainCombinations).it('properly updates short URL tags', async (shortCode, domain) => {
const expectedTags = [ 'foo', 'bar' ]; const expectedTags = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({ const axiosSpy = jest.fn(createAxiosMock({
data: { tags: expectedTags }, data: { tags: expectedTags },
})); }));
const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy); const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy);
const result = await updateShortUrlTags('abc123', expectedTags); const result = await updateShortUrlTags(shortCode, domain, expectedTags);
expect(expectedTags).toEqual(result); expect(expectedTags).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123/tags', url: `/short-urls/${shortCode}/tags`,
method: 'PUT', method: 'PUT',
params: domain ? { domain } : {},
})); }));
}); });
}); });
describe('updateShortUrlMeta', () => { describe('updateShortUrlMeta', () => {
it('properly updates short URL meta', async () => { each(shortCodesWithDomainCombinations).it('properly updates short URL meta', async (shortCode, domain) => {
const expectedMeta = { const expectedMeta = {
maxVisits: 50, maxVisits: 50,
validSince: '2025-01-01T10:00:00+01:00', validSince: '2025-01-01T10:00:00+01:00',
@@ -111,12 +119,13 @@ describe('ShlinkApiClient', () => {
const axiosSpy = jest.fn(createAxiosMock()); const axiosSpy = jest.fn(createAxiosMock());
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy); const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy);
const result = await updateShortUrlMeta('abc123', expectedMeta); const result = await updateShortUrlMeta(shortCode, domain, expectedMeta);
expect(expectedMeta).toEqual(result); expect(expectedMeta).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123', url: `/short-urls/${shortCode}`,
method: 'PATCH', method: 'PATCH',
params: domain ? { domain } : {},
})); }));
}); });
}); });
@@ -172,15 +181,16 @@ describe('ShlinkApiClient', () => {
}); });
describe('deleteShortUrl', () => { describe('deleteShortUrl', () => {
it('properly deletes provided short URL', async () => { each(shortCodesWithDomainCombinations).it('properly deletes provided short URL', async (shortCode, domain) => {
const axiosSpy = jest.fn(createAxiosMock({})); const axiosSpy = jest.fn(createAxiosMock({}));
const { deleteShortUrl } = new ShlinkApiClient(axiosSpy); const { deleteShortUrl } = new ShlinkApiClient(axiosSpy);
await deleteShortUrl('abc123'); await deleteShortUrl(shortCode, domain);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123', url: `/short-urls/${shortCode}`,
method: 'DELETE', method: 'DELETE',
params: domain ? { domain } : {},
})); }));
}); });
}); });

View File

@@ -17,15 +17,17 @@ describe('<ShortUrlVisits />', () => {
const match = { const match = {
params: { shortCode: 'abc123' }, params: { shortCode: 'abc123' },
}; };
const location = { search: '' };
const createComponent = (shortUrlVisits) => { const createComponent = (shortUrlVisits) => {
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }); const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }, () => '');
wrapper = shallow( wrapper = shallow(
<ShortUrlVisits <ShortUrlVisits
getShortUrlDetail={identity} getShortUrlDetail={identity}
getShortUrlVisits={getShortUrlVisitsMock} getShortUrlVisits={getShortUrlVisitsMock}
match={match} match={match}
location={location}
shortUrlVisits={shortUrlVisits} shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}} shortUrlDetail={{}}
cancelGetShortUrlVisits={identity} cancelGetShortUrlVisits={identity}

View File

@@ -26,7 +26,7 @@ describe('<VisitsHeader />', () => {
it('shows the amount of visits', () => { it('shows the amount of visits', () => {
const visitsBadge = wrapper.find('.badge'); const visitsBadge = wrapper.find('.badge');
expect(visitsBadge.html()).toContain(`Visits: <span>${shortUrlVisits.visits.length}</span>`); expect(visitsBadge.html()).toContain(`Visits: <span><strong>${shortUrlVisits.visits.length}</strong></span>`);
}); });
it('shows when the URL was created', () => { it('shows when the URL was created', () => {