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).
## [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

5
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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,
},
});
};

View file

@ -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');

View file

@ -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 (
<div className="shlink-container">
@ -89,15 +96,29 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('customSlug', 'Custom slug')}
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<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 })}
</div>
<div className="col-sm-3">
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div>
<div className="mb-3 text-right">
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={this.state.findIfExists}
@ -107,6 +128,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForVersion>
</Collapse>
<div>
@ -119,7 +141,10 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
&nbsp;
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</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'}
</button>
</div>

View file

@ -20,11 +20,11 @@ const renderInfoModal = (isOpen, toggle) => (
<ul>
<li>
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>
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.
<br />
If the slug is being used by another long URL, an error will be returned.
</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
</li>
</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>
</Modal>
);

View file

@ -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);

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';
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,

View file

@ -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]) {

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 { 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
);

View file

@ -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', () => {

View file

@ -32,6 +32,7 @@ describe('<CreateShortUrl />', () => {
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('<CreateShortUrl />', () => {
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('<CreateShortUrl />', () => {
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',

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 { 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);
});
});