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:
environment:
node: v12.11.0
node: v12.14.1
tools:
external_code_coverage:
timeout: 1200

View File

@@ -1,7 +1,7 @@
language: node_js
node_js:
- "12.11.0"
- "12.14.1"
cache:
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).
## 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
#### 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
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.
* 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

View File

@@ -5,7 +5,7 @@ server {
index index.html;
# When requesting static paths with extension, try them, and return a 404 if not found
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|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;
}

View File

@@ -3,7 +3,7 @@ version: '3'
services:
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"
volumes:
- ./:/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
[actionName]: lazyService(container, actionName),
});
const connect = (propsFromState, actionServiceNames) =>
const connect = (propsFromState, actionServiceNames = []) =>
reduxConnect(
propsFromState ? pick(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {})

View File

@@ -63,3 +63,11 @@ body,
.indivisible {
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,
url: PropTypes.string,
apiKey: PropTypes.string,
version: PropTypes.string,
});

View File

@@ -6,6 +6,7 @@ import DeleteServerButton from '../DeleteServerButton';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
import ForServerVersion from '../helpers/ForServerVersion';
import ServersImporter from './ServersImporter';
import ServersService from './ServersService';
import ServersExporter from './ServersExporter';
@@ -28,6 +29,9 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
// Services
bottle.constant('csvjson', 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 DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import ForVersion from '../utils/ForVersion';
import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
@@ -15,7 +14,11 @@ import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
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 = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
@@ -116,7 +119,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
</div>
</div>
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
@@ -127,7 +130,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForVersion>
</ForServerVersion>
</Collapse>
<div>

View File

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

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { identity } from 'ramda';
import { identity, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
@@ -15,21 +15,17 @@ export default class DeleteShortUrlModal extends React.Component {
shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func,
shortUrlDeleted: PropTypes.func,
};
state = { inputValue: '' };
handleDeleteUrl = (e) => {
e.preventDefault();
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props;
const { shortCode } = shortUrl;
const { deleteShortUrl, shortUrl, toggle } = this.props;
const { shortCode, domain } = shortUrl;
deleteShortUrl(shortCode)
.then(() => {
shortUrlDeleted(shortCode);
toggle();
})
deleteShortUrl(shortCode, domain)
.then(toggle)
.catch(identity);
};
@@ -40,16 +36,17 @@ export default class DeleteShortUrlModal extends React.Component {
}
render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props;
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<Modal isOpen={isOpen} toggle={close} centered>
<form onSubmit={this.handleDeleteUrl}>
<ModalHeader toggle={toggle}>
<ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
@@ -77,7 +74,7 @@ export default class DeleteShortUrlModal extends React.Component {
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button
type="submit"
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 { ExternalLink } from 'react-external-link';
import moment from 'moment';
import { pipe } from 'ramda';
import { isEmpty, pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput';
@@ -36,8 +36,8 @@ const EditMetaModal = (
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
const close = pipe(resetShortUrlMeta, toggle);
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, {
maxVisits: maxVisits && parseInt(maxVisits),
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null,
validSince: validSince && formatIsoDate(validSince),
validUntil: validUntil && formatIsoDate(validUntil),
}).then(close);

View File

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

View File

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

View File

@@ -58,7 +58,11 @@ const ShortUrlsRow = (
</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: ">
<ShortUrlVisitsCount visitsCount={shortUrl.visitsCount} meta={shortUrl.meta} />
<ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
/>
</td>
<td className="short-urls-row__cell short-urls-row__cell--relative">
<small

View File

@@ -10,21 +10,20 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { isEmpty } from 'ramda';
import { serverType } from '../../servers/prop-types';
import { compareVersions } from '../../utils/utils';
import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = (
DeleteShortUrlModal,
EditTagsModal,
EditMetaModal
EditMetaModal,
ForServerVersion
) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
onCopyToClipboard: PropTypes.func,
@@ -45,9 +44,6 @@ const ShortUrlsRowMenu = (
render() {
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
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 toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewModalOpen');
@@ -61,7 +57,7 @@ const ShortUrlsRowMenu = (
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<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
</DropdownItem>
@@ -70,14 +66,12 @@ const ShortUrlsRowMenu = (
</DropdownItem>
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
{showEditMetaBtn && (
<React.Fragment>
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
</React.Fragment>
)}
<ForServerVersion minVersion="1.18.0">
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
</ForServerVersion>
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
@@ -86,21 +80,21 @@ const ShortUrlsRowMenu = (
<DropdownItem divider />
{showPreviewBtn && (
<React.Fragment>
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
</React.Fragment>
)}
<ForServerVersion maxVersion="1.x">
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
</ForServerVersion>
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
{showPreviewBtn && <DropdownItem divider />}
<ForServerVersion maxVersion="1.x">
<DropdownItem divider />
</ForServerVersion>
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
<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 */
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
/* eslint-enable padding-line-between-statements */
export const shortUrlDeletionType = PropTypes.shape({
@@ -27,18 +26,18 @@ const initialState = {
export default handleActions({
[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]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
[RESET_DELETE_SHORT_URL]: () => 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 });
const { deleteShortUrl } = await buildShlinkApiClient(getState);
try {
await deleteShortUrl(shortCode);
dispatch({ type: DELETE_SHORT_URL, shortCode });
await deleteShortUrl(shortCode, domain);
dispatch({ type: SHORT_URL_DELETED, shortCode, domain });
} catch (e) {
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 shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View File

@@ -35,13 +35,13 @@ export default handleActions({
[RESET_EDIT_SHORT_URL_META]: () => 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 });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, meta);
dispatch({ shortCode, meta, type: SHORT_URL_META_EDITED });
await updateShortUrlMeta(shortCode, domain, meta);
dispatch({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_META_ERROR });

View File

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
/* 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_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 RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
/* eslint-enable padding-line-between-statements */
export const shortUrlTagsType = PropTypes.shape({
@@ -26,18 +25,18 @@ const initialState = {
export default handleActions({
[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]: (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,
}, 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 });
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
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) {
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 shortUrlTagsEdited = (shortCode, tags) => ({
tags,
shortCode,
type: SHORT_URL_TAGS_EDITED,
});

View File

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

View File

@@ -12,8 +12,8 @@ import EditMetaModal from '../helpers/EditMetaModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
@@ -24,8 +24,8 @@ const provideServices = (bottle, connect) => {
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
));
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams', 'selectedServer' ], [ 'listShortUrls' ]));
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect(
@@ -35,26 +35,27 @@ const provideServices = (bottle, connect) => {
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('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect(
[ 'shortUrlDeletion' ],
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
));
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
bottle.decorator('EditTagsModal', connect(
[ 'shortUrlTags' ],
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
));
bottle.decorator('EditTagsModal', connect([ 'shortUrlTags' ], [ 'editShortUrlTags', 'resetShortUrlsTags' ]));
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
@@ -62,7 +63,6 @@ const provideServices = (bottle, connect) => {
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
@@ -72,7 +72,6 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
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 { isEmpty, isNil, pipe, reject } from 'ramda';
import { isEmpty, isNil, reject } from 'ramda';
import PropTypes from 'prop-types';
export const apiErrorType = PropTypes.shape({
@@ -12,6 +12,7 @@ export const apiErrorType = PropTypes.shape({
});
const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = reject(isNil);
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
@@ -21,10 +22,8 @@ export default class ShlinkApiClient {
this._apiKey = apiKey || '';
}
listShortUrls = pipe(
(options = {}) => reject(isNil, options),
(options) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls)
);
listShortUrls = (options = {}) =>
this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls);
createShortUrl = (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)
.then((resp) => resp.data.visits);
getShortUrl = (shortCode) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET')
getShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
.then((resp) => resp.data);
deleteShortUrl = (shortCode) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE')
deleteShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => ({}));
updateShortUrlTags = (shortCode, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags })
updateShortUrlTags = (shortCode, domain, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then((resp) => resp.data.tags);
updateShortUrlMeta = (shortCode, meta) =>
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', {}, meta)
updateShortUrlMeta = (shortCode, domain, meta) =>
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
.then(() => meta);
listTags = () =>
@@ -73,7 +72,7 @@ export default class ShlinkApiClient {
method,
url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`,
headers: { 'X-Api-Key': this._apiKey },
params: query,
params: rejectNilProps(query),
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});

View File

@@ -4,6 +4,7 @@ import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
import qs from 'qs';
import DateRangeRow from '../utils/DateRangeRow';
import MutedMessage from '../utils/MuttedMessage';
import { formatDate } from '../utils/utils';
@@ -21,6 +22,9 @@ const ShortUrlVisits = (
match: PropTypes.shape({
params: PropTypes.object,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
getShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
@@ -29,24 +33,24 @@ const ShortUrlVisits = (
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props;
loadVisits = (loadDetail = false) => {
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
const { shortCode } = params;
const dates = mapObjIndexed(formatDate(), this.state);
const { startDate, endDate } = dates;
const { startDate, endDate } = mapObjIndexed(formatDate(), this.state);
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}`;
getShortUrlVisits(shortCode, dates);
getShortUrlVisits(shortCode, { startDate, endDate, domain });
if (loadDetail) {
getShortUrlDetail(shortCode, domain);
}
};
componentDidMount() {
const { match: { params }, getShortUrlDetail } = this.props;
const { shortCode } = params;
this.timeWhenMounted = new Date().getTime();
this.loadVisits();
getShortUrlDetail(shortCode);
this.loadVisits(true);
}
componentWillUnmount() {

View File

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

View File

@@ -26,13 +26,13 @@ export default handleActions({
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
}, 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 });
const { getShortUrl } = await buildShlinkApiClient(getState);
try {
const shortUrl = await getShortUrl(shortCode);
const shortUrl = await getShortUrl(shortCode, domain);
dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) {

View File

@@ -49,7 +49,7 @@ export default handleActions({
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
}, 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 });
const { getShortUrlVisits } = await buildShlinkApiClient(getState);
@@ -57,7 +57,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
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 || isLastPage(pagination)) {
@@ -96,7 +96,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const loadVisitsInParallel = (pages) =>
Promise.all(pages.map(
(page) =>
getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage })
getShortUrlVisits(shortCode, { ...query, page, itemsPerPage })
.then(prop('data'))
)).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();
beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '');
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '', () => '');
wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />

View File

@@ -9,7 +9,7 @@ import DateRangeRow from '../../src/utils/DateRangeRow';
describe('<SearchBar />', () => {
let wrapper;
const listShortUrlsMock = jest.fn();
const SearchBar = searchBarCreator({});
const SearchBar = searchBarCreator({}, () => '');
afterEach(() => {
listShortUrlsMock.mockReset();
@@ -22,15 +22,10 @@ describe('<SearchBar />', () => {
expect(wrapper.find(SearchField)).toHaveLength(1);
});
each([
[ '2.0.0', 1 ],
[ '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 }} />);
it('renders a DateRangeRow', () => {
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
expect(wrapper.find(DateRangeRow)).toHaveLength(expectedLength);
expect(wrapper.find(DateRangeRow)).toHaveLength(1);
});
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) => {
wrapper = shallow(
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} selectedServer={{ version: '2.0.0' }} />
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />
);
const dateRange = wrapper.find(DateRangeRow);

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ButtonDropdown, DropdownItem } from 'reactstrap';
import each from 'jest-each';
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
@@ -17,12 +16,12 @@ describe('<ShortUrlsRowMenu />', () => {
shortCode: 'abc123',
shortUrl: 'https://doma.in/abc123',
};
const createWrapper = (serverVersion = '1.21.1') => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal);
const createWrapper = () => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal, () => '');
wrapper = shallow(
<ShortUrlsRowMenu
selectedServer={{ ...selectedServer, version: serverVersion }}
selectedServer={selectedServer}
shortUrl={shortUrl}
onCopyToClipboard={onCopyToClipboard}
/>
@@ -46,24 +45,12 @@ describe('<ShortUrlsRowMenu />', () => {
expect(qrCodeModal).toHaveLength(1);
});
each([
[ '1.17.0', 6, 2 ],
[ '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);
it('renders correct amount of menu items', () => {
const wrapper = createWrapper();
const items = wrapper.find(DropdownItem);
expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems);
expect(items.find('[divider]')).toHaveLength(expectedDividerItems);
expect(items).toHaveLength(9);
expect(items.find('[divider]')).toHaveLength(2);
});
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, {
DELETE_SHORT_URL, DELETE_SHORT_URL_ERROR,
DELETE_SHORT_URL_ERROR,
DELETE_SHORT_URL_START,
RESET_DELETE_SHORT_URL,
SHORT_URL_DELETED,
resetDeleteShortUrl,
shortUrlDeleted,
deleteShortUrl,
} from '../../../src/short-urls/reducers/shortUrlDeletion';
@@ -26,8 +26,8 @@ describe('shortUrlDeletionReducer', () => {
errorData: {},
}));
it('returns shortCode on DELETE_SHORT_URL', () =>
expect(reducer(undefined, { type: DELETE_SHORT_URL, shortCode: 'foo' })).toEqual({
it('returns shortCode on SHORT_URL_DELETED', () =>
expect(reducer(undefined, { type: SHORT_URL_DELETED, shortCode: 'foo' })).toEqual({
shortCode: 'foo',
loading: false,
error: false,
@@ -51,11 +51,6 @@ describe('shortUrlDeletionReducer', () => {
expect(resetDeleteShortUrl()).toEqual({ type: RESET_DELETE_SHORT_URL }));
});
describe('shortUrlDeleted', () => {
it('returns expected action', () =>
expect(shortUrlDeleted('abc123')).toEqual({ type: SHORT_URL_DELETED, shortCode: 'abc123' }));
});
describe('deleteShortUrl', () => {
const dispatch = jest.fn();
const getState = jest.fn().mockReturnValue({ selectedServer: {} });
@@ -65,20 +60,22 @@ describe('shortUrlDeletionReducer', () => {
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 = {
deleteShortUrl: jest.fn(() => ''),
};
const shortCode = 'abc123';
await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
await deleteShortUrl(() => apiClientMock)(shortCode, domain)(dispatch, getState);
expect(dispatch).toHaveBeenCalledTimes(2);
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).toHaveBeenCalledWith(shortCode);
expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode, domain);
});
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(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 each from 'jest-each';
import reducer, {
EDIT_SHORT_URL_META_START,
EDIT_SHORT_URL_META_ERROR,
@@ -56,15 +57,15 @@ describe('shortUrlMetaReducer', () => {
afterEach(jest.clearAllMocks);
it('dispatches metadata on success', async () => {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, meta)(dispatch);
each([[ undefined ], [ null ], [ 'example.com' ]]).it('dispatches metadata on success', async (domain) => {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, domain, meta)(dispatch);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, meta);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, meta);
expect(dispatch).toHaveBeenCalledTimes(2);
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 () => {
@@ -73,14 +74,14 @@ describe('shortUrlMetaReducer', () => {
updateShortUrlMeta.mockRejectedValue(error);
try {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, meta)(dispatch);
await editShortUrlMeta(buildShlinkApiClient)(shortCode, undefined, meta)(dispatch);
} catch (e) {
expect(e).toBe(error);
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, meta);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, meta);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR });

View File

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

View File

@@ -38,15 +38,17 @@ describe('shortUrlsListReducer', () => {
shortUrls: {
data: [
{ shortCode, tags: [] },
{ shortCode, tags: [], domain: 'example.com' },
{ 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: {
data: [
{ shortCode, tags },
{ shortCode, tags: [], domain: 'example.com' },
{ shortCode: 'foo', tags: [] },
],
},
@@ -55,6 +57,7 @@ describe('shortUrlsListReducer', () => {
it('Updates meta on matching URL on SHORT_URL_META_EDITED', () => {
const shortCode = 'abc123';
const domain = 'example.com';
const meta = {
maxVisits: 5,
validSince: '2020-05-05',
@@ -62,16 +65,18 @@ describe('shortUrlsListReducer', () => {
const state = {
shortUrls: {
data: [
{ shortCode, meta: { maxVisits: 10 } },
{ shortCode, meta: { maxVisits: 10 }, domain },
{ shortCode, meta: { maxVisits: 50 } },
{ 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: {
data: [
{ shortCode, meta },
{ shortCode, meta, domain: 'example.com' },
{ shortCode, meta: { maxVisits: 50 } },
{ shortCode: 'foo', meta: null },
],
},
@@ -84,6 +89,7 @@ describe('shortUrlsListReducer', () => {
shortUrls: {
data: [
{ shortCode },
{ shortCode, domain: 'example.com' },
{ shortCode: 'foo' },
],
},
@@ -91,7 +97,7 @@ describe('shortUrlsListReducer', () => {
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode })).toEqual({
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';
describe('ShlinkApiClient', () => {
const createAxiosMock = (data) => () => Promise.resolve(data);
const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data));
const shortCodesWithDomainCombinations = [
[ 'abc123', null ],
[ 'abc123', undefined ],
[ 'abc123', 'example.com' ],
];
describe('listShortUrls', () => {
it('properly returns short URLs list', async () => {
@@ -67,43 +73,45 @@ describe('ShlinkApiClient', () => {
});
describe('getShortUrl', () => {
it('properly returns short URL', async () => {
each(shortCodesWithDomainCombinations).it('properly returns short URL', async (shortCode, domain) => {
const expectedShortUrl = { foo: 'bar' };
const axiosSpy = jest.fn(createAxiosMock({
data: expectedShortUrl,
}));
const { getShortUrl } = new ShlinkApiClient(axiosSpy);
const result = await getShortUrl('abc123');
const result = await getShortUrl(shortCode, domain);
expect(expectedShortUrl).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123',
url: `/short-urls/${shortCode}`,
method: 'GET',
params: domain ? { domain } : {},
}));
});
});
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 axiosSpy = jest.fn(createAxiosMock({
data: { tags: expectedTags },
}));
const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy);
const result = await updateShortUrlTags('abc123', expectedTags);
const result = await updateShortUrlTags(shortCode, domain, expectedTags);
expect(expectedTags).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123/tags',
url: `/short-urls/${shortCode}/tags`,
method: 'PUT',
params: domain ? { domain } : {},
}));
});
});
describe('updateShortUrlMeta', () => {
it('properly updates short URL meta', async () => {
each(shortCodesWithDomainCombinations).it('properly updates short URL meta', async (shortCode, domain) => {
const expectedMeta = {
maxVisits: 50,
validSince: '2025-01-01T10:00:00+01:00',
@@ -111,12 +119,13 @@ describe('ShlinkApiClient', () => {
const axiosSpy = jest.fn(createAxiosMock());
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy);
const result = await updateShortUrlMeta('abc123', expectedMeta);
const result = await updateShortUrlMeta(shortCode, domain, expectedMeta);
expect(expectedMeta).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123',
url: `/short-urls/${shortCode}`,
method: 'PATCH',
params: domain ? { domain } : {},
}));
});
});
@@ -172,15 +181,16 @@ describe('ShlinkApiClient', () => {
});
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 { deleteShortUrl } = new ShlinkApiClient(axiosSpy);
await deleteShortUrl('abc123');
await deleteShortUrl(shortCode, domain);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123',
url: `/short-urls/${shortCode}`,
method: 'DELETE',
params: domain ? { domain } : {},
}));
});
});

View File

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

View File

@@ -26,7 +26,7 @@ describe('<VisitsHeader />', () => {
it('shows the amount of visits', () => {
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', () => {