mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-03 23:07:26 +03:00
Merge pull request #162 from acelaya-forks/feature/domain
Feature/domain
This commit is contained in:
commit
a7f7666ccd
17 changed files with 197 additions and 30 deletions
23
CHANGELOG.md
23
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
|
||||
|
|
5
package-lock.json
generated
5
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,24 +96,39 @@ 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">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={this.state.findIfExists}
|
||||
onChange={(findIfExists) => this.setState({ findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</div>
|
||||
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
|
||||
<div className="mb-4 text-right">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={this.state.findIfExists}
|
||||
onChange={(findIfExists) => this.setState({ findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</div>
|
||||
</ForVersion>
|
||||
</Collapse>
|
||||
|
||||
<div>
|
||||
|
@ -119,7 +141,10 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
|
|||
|
||||
{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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
19
src/utils/ForVersion.js
Normal 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;
|
|
@ -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,
|
||||
|
|
|
@ -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]) {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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',
|
||||
|
|
43
test/utils/ForVersion.test.js
Normal file
43
test/utils/ForVersion.test.js
Normal 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>');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue