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 -
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) => ( -
- 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); + }); });