Merge pull request #162 from acelaya-forks/feature/domain

Feature/domain
This commit is contained in:
Alejandro Celaya 2019-10-05 11:15:09 +02:00 committed by GitHub
commit a7f7666ccd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 197 additions and 30 deletions

View file

@ -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). 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 ## 2.1.1 - 2019-09-22
#### Added #### Added

5
package-lock.json generated
View file

@ -4951,6 +4951,11 @@
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true "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": { "component-emitter": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz", "resolved": "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz",

View file

@ -35,6 +35,7 @@
"bottlejs": "^1.7.1", "bottlejs": "^1.7.1",
"chart.js": "^2.7.2", "chart.js": "^2.7.2",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"compare-versions": "^3.5.1",
"csvjson": "^5.1.0", "csvjson": "^5.1.0",
"leaflet": "^1.4.0", "leaflet": "^1.4.0",
"moment": "^2.22.2", "moment": "^2.22.2",

View file

@ -10,14 +10,19 @@ const initialState = null;
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER); export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = ({ findServerById }) => (serverId) => (dispatch) => { export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
dispatch(resetShortUrlParams()); dispatch(resetShortUrlParams());
const selectedServer = findServerById(serverId); const selectedServer = findServerById(serverId);
const { health } = await buildShlinkApiClient(selectedServer);
const { version } = await health();
dispatch({ dispatch({
type: SELECT_SERVER, type: SELECT_SERVER,
selectedServer, selectedServer: {
...selectedServer,
version,
},
}); });
}; };

View file

@ -34,7 +34,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson'); bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions // Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService'); bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers'); bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers'); bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers'); bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda'; import { range } from 'ramda';
import { useState } from 'react'; import { useState } from 'react';
import { compare } from 'compare-versions';
const TEN_ROUNDING_NUMBER = 10; const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000; 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 wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
export const compareVersions = (firstVersion, operator, secondVersion) => compare(
firstVersion,
secondVersion,
operator
);

View file

@ -29,22 +29,30 @@ describe('selectedServerReducer', () => {
const selectedServer = { const selectedServer = {
id: serverId, id: serverId,
}; };
const version = '1.19.0';
const ServersServiceMock = { const ServersServiceMock = {
findServerById: jest.fn(() => selectedServer), findServerById: jest.fn(() => selectedServer),
}; };
const apiClientMock = {
health: jest.fn().mockResolvedValue({ version }),
};
afterEach(() => { afterEach(() => {
ServersServiceMock.findServerById.mockClear(); ServersServiceMock.findServerById.mockClear();
}); });
it('dispatches proper actions', () => { it('dispatches proper actions', async () => {
const dispatch = jest.fn(); 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).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SHORT_URL_PARAMS }); 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', () => { it('invokes dependencies', () => {

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder'; import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder';
import { buildShlinkBaseUrl } from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClientBuilder', () => { describe('ShlinkApiClientBuilder', () => {
const createBuilder = () => { const createBuilder = () => {
@ -33,4 +34,13 @@ describe('ShlinkApiClientBuilder', () => {
expect(firstApiClient).toBe(thirdApiClient); expect(firstApiClient).toBe(thirdApiClient);
expect(secondApiClient).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);
});
}); });