diff --git a/.eslintrc b/.eslintrc index b14ea6b0..3a282c9c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -28,6 +28,7 @@ "no-warning-comments": "off", "no-magic-numbers": "off", "no-undefined": "off", + "no-inline-comments": "off", "indent": ["error", 2, { "SwitchCase": 1 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 563a118a..d56f3d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), #### Added -* *Nothing* +* [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions. #### Changed diff --git a/src/common/ErrorHandler.js b/src/common/ErrorHandler.js index 6dc0af26..96daf12a 100644 --- a/src/common/ErrorHandler.js +++ b/src/common/ErrorHandler.js @@ -3,6 +3,7 @@ import * as PropTypes from 'prop-types'; import './ErrorHandler.scss'; import { Button } from 'reactstrap'; +// FIXME Replace with typescript: (window, console) const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component { static propTypes = { children: PropTypes.node.isRequired, diff --git a/src/short-urls/helpers/DeleteShortUrlModal.js b/src/short-urls/helpers/DeleteShortUrlModal.js index 1d2f0cc0..0a50d53f 100644 --- a/src/short-urls/helpers/DeleteShortUrlModal.js +++ b/src/short-urls/helpers/DeleteShortUrlModal.js @@ -5,6 +5,8 @@ import { identity } from 'ramda'; import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlDeletionType } from '../reducers/shortUrlDeletion'; +const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION'; + export default class DeleteShortUrlModal extends React.Component { static propTypes = { shortUrl: shortUrlType, @@ -39,9 +41,10 @@ export default class DeleteShortUrlModal extends React.Component { render() { const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props; - const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION'; - const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED; - const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED; + const { error, errorData } = shortUrlDeletion; + const errorCode = error && (errorData.type || errorData.error); + const hasThresholdError = errorCode === THRESHOLD_REACHED; + const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED; return ( @@ -63,7 +66,8 @@ export default class DeleteShortUrlModal extends React.Component { {hasThresholdError && (
- This short URL has received too many visits and therefore, it cannot be deleted + {errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`} + {!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
)} {hasErrorOtherThanThreshold && ( diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.js b/src/short-urls/helpers/ShortUrlsRowMenu.js index 19c7fea0..b5df90f8 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.js +++ b/src/short-urls/helpers/ShortUrlsRowMenu.js @@ -12,7 +12,9 @@ import { CopyToClipboard } from 'react-copy-to-clipboard'; import { Link } from 'react-router-dom'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import PropTypes from 'prop-types'; +import { isEmpty } from 'ramda'; import { serverType } from '../../servers/prop-types'; +import { compareVersions } from '../../utils/utils'; import { shortUrlType } from '../reducers/shortUrlsList'; import PreviewModal from './PreviewModal'; import QrCodeModal from './QrCodeModal'; @@ -37,6 +39,8 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls render() { const { onCopyToClipboard, shortUrl, selectedServer } = this.props; const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; + const currentServerVersion = selectedServer ? selectedServer.version : ''; + const showPreviewBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '<', '2.0.0'); const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] })); const toggleQrCode = toggleModal('isQrModalOpen'); const togglePreview = toggleModal('isPreviewModalOpen'); @@ -70,17 +74,21 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls - -  Preview - - + {showPreviewBtn && ( + + +  Preview + + + + )}  QR code - + {showPreviewBtn && } diff --git a/src/short-urls/reducers/shortUrlDeletion.js b/src/short-urls/reducers/shortUrlDeletion.js index 62d63208..6e57b607 100644 --- a/src/short-urls/reducers/shortUrlDeletion.js +++ b/src/short-urls/reducers/shortUrlDeletion.js @@ -1,5 +1,6 @@ import { createAction, handleActions } from 'redux-actions'; import PropTypes from 'prop-types'; +import { apiErrorType } from '../../utils/services/ShlinkApiClient'; /* eslint-disable padding-line-between-statements */ export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START'; @@ -13,10 +14,7 @@ export const shortUrlDeletionType = PropTypes.shape({ shortCode: PropTypes.string.isRequired, loading: PropTypes.bool.isRequired, error: PropTypes.bool.isRequired, - errorData: PropTypes.shape({ - error: PropTypes.string, - message: PropTypes.string, - }).isRequired, + errorData: apiErrorType.isRequired, }); const initialState = { diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js index ac0b6fbf..2fe24391 100644 --- a/src/utils/services/ShlinkApiClient.js +++ b/src/utils/services/ShlinkApiClient.js @@ -1,14 +1,23 @@ import qs from 'qs'; import { isEmpty, isNil, reject } from 'ramda'; +import PropTypes from 'prop-types'; -const API_VERSION = '1'; +export const apiErrorType = PropTypes.shape({ + type: PropTypes.string, + detail: PropTypes.string, + title: PropTypes.string, + status: PropTypes.number, + error: PropTypes.string, // Deprecated + message: PropTypes.string, // Deprecated +}); -export const buildShlinkBaseUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : ''; +const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : ''; export default class ShlinkApiClient { constructor(axios, baseUrl, apiKey) { this.axios = axios; - this._baseUrl = buildShlinkBaseUrl(baseUrl); + this._apiVersion = 2; + this._baseUrl = baseUrl; this._apiKey = apiKey || ''; } @@ -53,13 +62,35 @@ export default class ShlinkApiClient { health = () => this._performRequest('/health', 'GET').then((resp) => resp.data); - _performRequest = async (url, method = 'GET', query = {}, body = {}) => - await this.axios({ - method, - url: `${this._baseUrl}${url}`, - headers: { 'X-Api-Key': this._apiKey }, - params: query, - data: body, - paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }), - }); + _performRequest = async (url, method = 'GET', query = {}, body = {}) => { + try { + return await this.axios({ + method, + url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`, + headers: { 'X-Api-Key': this._apiKey }, + params: query, + data: body, + paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }), + }); + } catch (e) { + const { response } = e; + + // Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error + // when performed from the browser (due to the preflight request not returning a 2xx status. + // See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here. + // The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as + // if a request has been performed to a not supported API version. + const apiVersionIsNotSupported = !response; + + // When the request is not invalid or we have already tried both API versions, throw the error and let the + // caller handle it + if (!apiVersionIsNotSupported || this._apiVersion === 1) { + throw e; + } + + this._apiVersion = 1; + + return await this._performRequest(url, method, query, body); + } + } } diff --git a/test/short-urls/helpers/DeleteShortUrlModal.test.js b/test/short-urls/helpers/DeleteShortUrlModal.test.js index 204a2779..bfc48b65 100644 --- a/test/short-urls/helpers/DeleteShortUrlModal.test.js +++ b/test/short-urls/helpers/DeleteShortUrlModal.test.js @@ -1,6 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { identity } from 'ramda'; +import each from 'jest-each'; import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal'; describe('', () => { @@ -32,17 +33,34 @@ describe('', () => { deleteShortUrl.mockClear(); }); - it('shows threshold error message when threshold error occurs', () => { + each([ + [ + { error: 'INVALID_SHORTCODE_DELETION' }, + 'This short URL has received too many visits, and therefore, it cannot be deleted.', + ], + [ + { type: 'INVALID_SHORTCODE_DELETION' }, + 'This short URL has received too many visits, and therefore, it cannot be deleted.', + ], + [ + { error: 'INVALID_SHORTCODE_DELETION', threshold: 35 }, + 'This short URL has received more than 35 visits, and therefore, it cannot be deleted.', + ], + [ + { type: 'INVALID_SHORTCODE_DELETION', threshold: 8 }, + 'This short URL has received more than 8 visits, and therefore, it cannot be deleted.', + ], + ]).it('shows threshold error message when threshold error occurs', (errorData, expectedMessage) => { const wrapper = createWrapper({ loading: false, error: true, shortCode: 'abc123', - errorData: { error: 'INVALID_SHORTCODE_DELETION' }, + errorData, }); const warning = wrapper.find('.bg-warning'); expect(warning).toHaveLength(1); - expect(warning.html()).toContain('This short URL has received too many visits and therefore, it cannot be deleted'); + expect(warning.html()).toContain(expectedMessage); }); it('shows generic error when non-threshold error occurs', () => { diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.js b/test/short-urls/helpers/ShortUrlsRowMenu.test.js index a17744d2..59900bfb 100644 --- a/test/short-urls/helpers/ShortUrlsRowMenu.test.js +++ b/test/short-urls/helpers/ShortUrlsRowMenu.test.js @@ -1,6 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ButtonDropdown, DropdownItem } from 'reactstrap'; +import each from 'jest-each'; import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu'; import PreviewModal from '../../../src/short-urls/helpers/PreviewModal'; import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal'; @@ -15,18 +16,24 @@ describe('', () => { shortCode: 'abc123', shortUrl: 'https://doma.in/abc123', }; - - beforeEach(() => { + const createWrapper = (serverVersion = '1.21.1') => { const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal); wrapper = shallow( - + ); - }); - afterEach(() => wrapper.unmount()); + return wrapper; + }; + + afterEach(() => wrapper && wrapper.unmount()); it('renders modal windows', () => { + const wrapper = createWrapper(); const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal); const editTagsModal = wrapper.find(EditTagsModal); const previewModal = wrapper.find(PreviewModal); @@ -38,10 +45,16 @@ describe('', () => { expect(qrCodeModal).toHaveLength(1); }); - it('renders correct amount of menu items', () => { + each([ + [ '1.20.3', 6, 2 ], + [ '1.21.0', 6, 2 ], + [ '1.21.1', 6, 2 ], + [ '2.0.0', 5, 1 ], + [ '2.0.1', 5, 1 ], + [ '2.1.0', 5, 1 ], + ]).it('renders correct amount of menu items depending on the version', (version, expectedNonDividerItems, expectedDividerItems) => { + const wrapper = createWrapper(version); const items = wrapper.find(DropdownItem); - const expectedNonDividerItems = 6; - const expectedDividerItems = 2; expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems); expect(items.find('[divider]')).toHaveLength(expectedDividerItems); @@ -49,6 +62,7 @@ describe('', () => { describe('toggles state when toggling modal windows', () => { const assert = (modalComponent, stateProp, done) => { + const wrapper = createWrapper(); const modal = wrapper.find(modalComponent); expect(wrapper.state(stateProp)).toEqual(false); @@ -66,6 +80,7 @@ describe('', () => { }); it('toggles dropdown state when toggling dropdown', (done) => { + const wrapper = createWrapper(); const dropdown = wrapper.find(ButtonDropdown); expect(wrapper.state('isOpen')).toEqual(false); diff --git a/test/utils/services/ShlinkApiClientBuilder.test.js b/test/utils/services/ShlinkApiClientBuilder.test.js index 02ef8cd8..74ee7df6 100644 --- a/test/utils/services/ShlinkApiClientBuilder.test.js +++ b/test/utils/services/ShlinkApiClientBuilder.test.js @@ -1,5 +1,4 @@ import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder'; -import { buildShlinkBaseUrl } from '../../../src/utils/services/ShlinkApiClient'; describe('ShlinkApiClientBuilder', () => { const createBuilder = () => { @@ -40,7 +39,7 @@ describe('ShlinkApiClientBuilder', () => { const apiKey = 'apiKey'; const apiClient = await buildShlinkApiClient({})({ url, apiKey }); - expect(apiClient._baseUrl).toEqual(buildShlinkBaseUrl(url)); + expect(apiClient._baseUrl).toEqual(url); expect(apiClient._apiKey).toEqual(apiKey); }); });