diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15b5479d..b74f4a13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,29 @@ 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).
+## [Unreleased]
+
+#### Added
+
+* [#144](https://github.com/shlinkio/shlink-web-client/issues/144) Added domain input to create domain page.
+
+#### Changed
+
+* *Nothing*
+
+#### Deprecated
+
+* *Nothing*
+
+#### Removed
+
+* *Nothing*
+
+#### Fixed
+
+* *Nothing*
+
+
## 2.1.1 - 2019-09-22
#### Added
diff --git a/package-lock.json b/package-lock.json
index 1aad2d52..d0b8d605 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4951,6 +4951,11 @@
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true
},
+ "compare-versions": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.5.1.tgz",
+ "integrity": "sha512-9fGPIB7C6AyM18CJJBHt5EnCZDG3oiTJYy0NjfIAGjKpzv0tkxWko7TNQHF5ymqm7IH03tqmeuBxtvD+Izh6mg=="
+ },
"component-emitter": {
"version": "1.2.1",
"resolved": "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz",
diff --git a/package.json b/package.json
index 09dc06f4..e7195a3d 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"bottlejs": "^1.7.1",
"chart.js": "^2.7.2",
"classnames": "^2.2.6",
+ "compare-versions": "^3.5.1",
"csvjson": "^5.1.0",
"leaflet": "^1.4.0",
"moment": "^2.22.2",
diff --git a/src/servers/reducers/selectedServer.js b/src/servers/reducers/selectedServer.js
index bd14e80f..cef75410 100644
--- a/src/servers/reducers/selectedServer.js
+++ b/src/servers/reducers/selectedServer.js
@@ -10,14 +10,19 @@ const initialState = null;
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
-export const selectServer = ({ findServerById }) => (serverId) => (dispatch) => {
+export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = findServerById(serverId);
+ const { health } = await buildShlinkApiClient(selectedServer);
+ const { version } = await health();
dispatch({
type: SELECT_SERVER,
- selectedServer,
+ selectedServer: {
+ ...selectedServer,
+ version,
+ },
});
};
diff --git a/src/servers/services/provideServices.js b/src/servers/services/provideServices.js
index 0b135491..12755474 100644
--- a/src/servers/services/provideServices.js
+++ b/src/servers/services/provideServices.js
@@ -34,7 +34,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions
- bottle.serviceFactory('selectServer', selectServer, 'ServersService');
+ bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js
index 38d1d4be..57d604af 100644
--- a/src/short-urls/CreateShortUrl.js
+++ b/src/short-urls/CreateShortUrl.js
@@ -1,11 +1,14 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { assoc, dissoc, isNil, pipe, replace, trim } from 'ramda';
+import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react';
import { Collapse } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
+import ForVersion from '../utils/ForVersion';
+import { serverType } from '../servers/prop-types';
+import { compareVersions } from '../utils/utils';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
@@ -17,12 +20,14 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
+ selectedServer: serverType,
};
state = {
longUrl: '',
tags: [],
customSlug: undefined,
+ domain: undefined,
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
@@ -66,6 +71,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
assoc('validUntil', formatDate(this.state.validUntil))
)(this.state));
};
+ const currentServerVersion = this.props.selectedServer ? this.props.selectedServer.version : '';
+ const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
return (
@@ -89,24 +96,39 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
{renderOptionalInput('customSlug', 'Custom slug')}
- {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
+ {renderOptionalInput('domain', 'Domain', 'text', {
+ disabled: disableDomain,
+ ...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
+ })}
+
+
+
+
+
+ {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
+
+
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
+
+
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
-
- this.setState({ findIfExists })}
- >
- Use existing URL if found
-
-
-
+
+
+ this.setState({ findIfExists })}
+ >
+ Use existing URL if found
+
+
+
+
@@ -119,7 +141,10 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
-
+
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
diff --git a/src/short-urls/UseExistingIfFoundInfoIcon.js b/src/short-urls/UseExistingIfFoundInfoIcon.js
index 7e155ac4..851f04a5 100644
--- a/src/short-urls/UseExistingIfFoundInfoIcon.js
+++ b/src/short-urls/UseExistingIfFoundInfoIcon.js
@@ -20,11 +20,11 @@ const renderInfoModal = (isOpen, toggle) => (
When only the long URL is provided: The most recent match will be returned, or a new short URL will be created
- if none is found
+ if none is found.
- When long URL and custom slug are provided: Same as in previous case, but it will try to match the short URL
- using both the long URL and the slug.
+ When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match
+ the short URL using both the long URL and the slug, the long URL and the domain, or the three of them.
If the slug is being used by another long URL, an error will be returned.
@@ -33,9 +33,6 @@ const renderInfoModal = (isOpen, toggle) => (
all provided data. If any of them does not match, a new short URL will be created
-
- Important: This feature will be ignored while using a Shlink version older than v1.16.0.
-
);
diff --git a/src/short-urls/services/provideServices.js b/src/short-urls/services/provideServices.js
index 05518828..c0fe583b 100644
--- a/src/short-urls/services/provideServices.js
+++ b/src/short-urls/services/provideServices.js
@@ -39,7 +39,7 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
bottle.decorator(
'CreateShortUrl',
- connect([ 'shortUrlCreationResult' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
+ connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
diff --git a/src/utils/ForVersion.js b/src/utils/ForVersion.js
new file mode 100644
index 00000000..7ed39890
--- /dev/null
+++ b/src/utils/ForVersion.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { isEmpty } from 'ramda';
+import { compareVersions } from './utils';
+
+const propTypes = {
+ minVersion: PropTypes.string.isRequired,
+ currentServerVersion: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+const ForVersion = ({ minVersion, currentServerVersion, children }) =>
+ isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', minVersion)
+ ? null
+ :
{children} ;
+
+ForVersion.propTypes = propTypes;
+
+export default ForVersion;
diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js
index 42d27512..ac0b6fbf 100644
--- a/src/utils/services/ShlinkApiClient.js
+++ b/src/utils/services/ShlinkApiClient.js
@@ -2,12 +2,13 @@ import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
-const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
+
+export const buildShlinkBaseUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
this.axios = axios;
- this._baseUrl = buildRestUrl(baseUrl);
+ this._baseUrl = buildShlinkBaseUrl(baseUrl);
this._apiKey = apiKey || '';
}
@@ -50,6 +51,8 @@ export default class ShlinkApiClient {
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }));
+ health = () => this._performRequest('/health', 'GET').then((resp) => resp.data);
+
_performRequest = async (url, method = 'GET', query = {}, body = {}) =>
await this.axios({
method,
diff --git a/src/utils/services/ShlinkApiClientBuilder.js b/src/utils/services/ShlinkApiClientBuilder.js
index c239bbc6..be170789 100644
--- a/src/utils/services/ShlinkApiClientBuilder.js
+++ b/src/utils/services/ShlinkApiClientBuilder.js
@@ -13,8 +13,10 @@ const getSelectedServerFromState = async (getState) => {
return selectedServer;
};
-const buildShlinkApiClient = (axios) => async (getState) => {
- const { url, apiKey } = await getSelectedServerFromState(getState);
+const buildShlinkApiClient = (axios) => async (getStateOrSelectedServer) => {
+ const { url, apiKey } = typeof getStateOrSelectedServer === 'function'
+ ? await getSelectedServerFromState(getStateOrSelectedServer)
+ : getStateOrSelectedServer;
const clientKey = `${url}_${apiKey}`;
if (!apiClients[clientKey]) {
diff --git a/src/utils/utils.js b/src/utils/utils.js
index b1daf53b..3edcb7fb 100644
--- a/src/utils/utils.js
+++ b/src/utils/utils.js
@@ -4,6 +4,7 @@ import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda';
import { useState } from 'react';
+import { compare } from 'compare-versions';
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
@@ -53,3 +54,9 @@ export const useToggle = (initialValue = false) => {
};
export const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+export const compareVersions = (firstVersion, operator, secondVersion) => compare(
+ firstVersion,
+ secondVersion,
+ operator
+);
diff --git a/test/servers/reducers/selectedServer.test.js b/test/servers/reducers/selectedServer.test.js
index dcab78e6..63f2d87a 100644
--- a/test/servers/reducers/selectedServer.test.js
+++ b/test/servers/reducers/selectedServer.test.js
@@ -29,22 +29,30 @@ describe('selectedServerReducer', () => {
const selectedServer = {
id: serverId,
};
+ const version = '1.19.0';
const ServersServiceMock = {
findServerById: jest.fn(() => selectedServer),
};
+ const apiClientMock = {
+ health: jest.fn().mockResolvedValue({ version }),
+ };
afterEach(() => {
ServersServiceMock.findServerById.mockClear();
});
- it('dispatches proper actions', () => {
+ it('dispatches proper actions', async () => {
const dispatch = jest.fn();
+ const expectedSelectedServer = {
+ ...selectedServer,
+ version,
+ };
- selectServer(ServersServiceMock)(serverId)(dispatch);
+ await selectServer(ServersServiceMock, async () => apiClientMock)(serverId)(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SHORT_URL_PARAMS });
- expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer });
+ expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
});
it('invokes dependencies', () => {
diff --git a/test/short-urls/CreateShortUrl.test.js b/test/short-urls/CreateShortUrl.test.js
index f7b08096..6d281b7c 100644
--- a/test/short-urls/CreateShortUrl.test.js
+++ b/test/short-urls/CreateShortUrl.test.js
@@ -32,6 +32,7 @@ describe('
', () => {
const urlInput = wrapper.find('.form-control-lg');
const tagsInput = wrapper.find(TagsSelector);
const customSlugInput = wrapper.find('#customSlug');
+ const domain = wrapper.find('#domain');
const maxVisitsInput = wrapper.find('#maxVisits');
const dateInputs = wrapper.find(DateInput);
const validSinceInput = dateInputs.at(0);
@@ -40,6 +41,7 @@ describe('
', () => {
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
+ domain.simulate('change', { target: { value: 'example.com' } });
maxVisitsInput.simulate('change', { target: { value: '20' } });
validSinceInput.simulate('change', validSince);
validUntilInput.simulate('change', validUntil);
@@ -53,6 +55,7 @@ describe('
', () => {
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
+ domain: 'example.com',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
diff --git a/test/utils/ForVersion.test.js b/test/utils/ForVersion.test.js
new file mode 100644
index 00000000..83be6fc5
--- /dev/null
+++ b/test/utils/ForVersion.test.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import ForVersion from '../../src/utils/ForVersion';
+
+describe('
', () => {
+ let wrapped;
+
+ const renderComponent = (minVersion, currentServerVersion) => {
+ wrapped = mount(
+
+ Hello
+
+ );
+
+ 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('
Hello ');
+ });
+
+ it('renders children when current version is higher than min version', () => {
+ const wrapped = renderComponent('2.0.0', '2.1.0');
+
+ expect(wrapped.html()).toContain('
Hello ');
+ });
+});
diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js
index 024febf3..c2ef2f2a 100644
--- a/test/utils/services/ShlinkApiClient.test.js
+++ b/test/utils/services/ShlinkApiClient.test.js
@@ -165,4 +165,20 @@ describe('ShlinkApiClient', () => {
}));
});
});
+
+ describe('health', () => {
+ it('returns health data', async () => {
+ const expectedData = {
+ status: 'pass',
+ version: '1.19.0',
+ };
+ const axiosSpy = jest.fn(createAxiosMock({ data: expectedData }));
+ const { health } = new ShlinkApiClient(axiosSpy);
+
+ const result = await health();
+
+ expect(axiosSpy).toHaveBeenCalled();
+ expect(result).toEqual(expectedData);
+ });
+ });
});
diff --git a/test/utils/services/ShlinkApiClientBuilder.test.js b/test/utils/services/ShlinkApiClientBuilder.test.js
index c6875ed0..02ef8cd8 100644
--- a/test/utils/services/ShlinkApiClientBuilder.test.js
+++ b/test/utils/services/ShlinkApiClientBuilder.test.js
@@ -1,4 +1,5 @@
import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder';
+import { buildShlinkBaseUrl } from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClientBuilder', () => {
const createBuilder = () => {
@@ -33,4 +34,13 @@ describe('ShlinkApiClientBuilder', () => {
expect(firstApiClient).toBe(thirdApiClient);
expect(secondApiClient).toBe(thirdApiClient);
});
+
+ it('does not fetch from state when provided param is already selected server', async () => {
+ const url = 'url';
+ const apiKey = 'apiKey';
+ const apiClient = await buildShlinkApiClient({})({ url, apiKey });
+
+ expect(apiClient._baseUrl).toEqual(buildShlinkBaseUrl(url));
+ expect(apiClient._apiKey).toEqual(apiKey);
+ });
});