- {!this.props.tagsList.loading && (
-
- )}
+ {!this.props.tagsList.loading &&
+
+ }
{this.renderContent()}
diff --git a/src/tags/reducers/tagDelete.js b/src/tags/reducers/tagDelete.js
index 3b70f460..dfd2bf4a 100644
--- a/src/tags/reducers/tagDelete.js
+++ b/src/tags/reducers/tagDelete.js
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
-const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
-const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
-const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
+export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
+export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
+export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
diff --git a/src/tags/reducers/tagEdit.js b/src/tags/reducers/tagEdit.js
index a69184e0..650f0ba1 100644
--- a/src/tags/reducers/tagEdit.js
+++ b/src/tags/reducers/tagEdit.js
@@ -3,9 +3,9 @@ import shlinkApiClient from '../../api/ShlinkApiClient';
import colorGenerator from '../../utils/ColorGenerator';
/* eslint-disable padding-line-between-statements, newline-after-var */
-const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
-const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
-const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
+export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
+export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
+export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
/* eslint-enable padding-line-between-statements, newline-after-var */
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
@@ -42,20 +42,19 @@ export default function reducer(state = defaultState, action) {
}
}
-export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) =>
- async (dispatch) => {
- dispatch({ type: EDIT_TAG_START });
+export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) => async (dispatch) => {
+ dispatch({ type: EDIT_TAG_START });
- try {
- await shlinkApiClient.editTag(oldName, newName);
- colorGenerator.setColorForKey(newName, color);
- dispatch({ type: EDIT_TAG, oldName, newName });
- } catch (e) {
- dispatch({ type: EDIT_TAG_ERROR });
+ try {
+ await shlinkApiClient.editTag(oldName, newName);
+ colorGenerator.setColorForKey(newName, color);
+ dispatch({ type: EDIT_TAG, oldName, newName });
+ } catch (e) {
+ dispatch({ type: EDIT_TAG_ERROR });
- throw e;
- }
- };
+ throw e;
+ }
+};
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);
diff --git a/src/utils/DateInput.js b/src/utils/DateInput.js
new file mode 100644
index 00000000..9df330df
--- /dev/null
+++ b/src/utils/DateInput.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import { isNil } from 'ramda';
+import DatePicker from 'react-datepicker';
+import FontAwesomeIcon from '@fortawesome/react-fontawesome';
+import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
+import * as PropTypes from 'prop-types';
+import './DateInput.scss';
+
+const propTypes = {
+ className: PropTypes.string,
+ isClearable: PropTypes.bool,
+ selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
+ ref: PropTypes.object,
+};
+
+const DateInput = (props) => {
+ const { className, isClearable, selected, ref = React.createRef() } = props;
+ const showCalendarIcon = !isClearable || isNil(selected);
+
+ return (
+
+
+ {showCalendarIcon && (
+ ref.current.input.focus()}
+ />
+ )}
+
+ );
+};
+
+DateInput.propTypes = propTypes;
+
+export default DateInput;
diff --git a/src/common/DateInput.scss b/src/utils/DateInput.scss
similarity index 88%
rename from src/common/DateInput.scss
rename to src/utils/DateInput.scss
index 3c759665..5f2e23ff 100644
--- a/src/common/DateInput.scss
+++ b/src/utils/DateInput.scss
@@ -1,5 +1,5 @@
-@import '../utils/mixins/vertical-align';
-@import '../utils/base';
+@import './mixins/vertical-align';
+@import './base';
.date-input-container {
position: relative;
diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js
index e84fd713..037157de 100644
--- a/src/visits/ShortUrlVisits.js
+++ b/src/visits/ShortUrlVisits.js
@@ -5,7 +5,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
-import DateInput from '../common/DateInput';
+import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage';
import SortableBarGraph from './SortableBarGraph';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
diff --git a/test/short-urls/CreateShortUrl.test.js b/test/short-urls/CreateShortUrl.test.js
new file mode 100644
index 00000000..041fb1c3
--- /dev/null
+++ b/test/short-urls/CreateShortUrl.test.js
@@ -0,0 +1,66 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import moment from 'moment';
+import * as sinon from 'sinon';
+import { identity } from 'ramda';
+import { CreateShortUrlComponent as CreateShortUrl } from '../../src/short-urls/CreateShortUrl';
+import TagsSelector from '../../src/tags/helpers/TagsSelector';
+import DateInput from '../../src/utils/DateInput';
+
+describe('
', () => {
+ let wrapper;
+ const shortUrlCreationResult = {
+ loading: false,
+ };
+ const createShortUrl = sinon.spy();
+
+ beforeEach(() => {
+ wrapper = shallow(
+
+ );
+ });
+ afterEach(() => {
+ wrapper.unmount();
+ createShortUrl.resetHistory();
+ });
+
+ it('saves short URL with data set in form controls', (done) => {
+ const validSince = moment('2017-01-01');
+ const validUntil = moment('2017-01-06');
+
+ const urlInput = wrapper.find('.form-control-lg');
+ const tagsInput = wrapper.find(TagsSelector);
+ const customSlugInput = wrapper.find('#customSlug');
+ const maxVisitsInput = wrapper.find('#maxVisits');
+ const dateInputs = wrapper.find(DateInput);
+ const validSinceInput = dateInputs.at(0);
+ const validUntilInput = dateInputs.at(1);
+
+ 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' } });
+ maxVisitsInput.simulate('change', { target: { value: '20' } });
+ validSinceInput.simulate('change', validSince);
+ validUntilInput.simulate('change', validUntil);
+
+ setImmediate(() => {
+ const form = wrapper.find('form');
+
+ form.simulate('submit', { preventDefault: identity });
+ expect(createShortUrl.callCount).toEqual(1);
+ expect(createShortUrl.getCall(0).args).toEqual(
+ [
+ {
+ longUrl: 'https://long-domain.com/foo/bar',
+ tags: [ 'tag_foo', 'tag_bar' ],
+ customSlug: 'my-slug',
+ validSince: validSince.format(),
+ validUntil: validUntil.format(),
+ maxVisits: '20',
+ },
+ ]
+ );
+ done();
+ });
+ });
+});
diff --git a/test/short-urls/ShortUrls.test.js b/test/short-urls/ShortUrls.test.js
new file mode 100644
index 00000000..9cccc9e9
--- /dev/null
+++ b/test/short-urls/ShortUrls.test.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { ShortUrlsComponent as ShortUrls } from '../../src/short-urls/ShortUrls';
+import Paginator from '../../src/short-urls/Paginator';
+import ShortUrlsList from '../../src/short-urls/ShortUrlsList';
+import SearchBar from '../../src/short-urls/SearchBar';
+
+describe('
', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ const params = {
+ serverId: '1',
+ page: '1',
+ };
+
+ wrapper = shallow(
);
+ });
+ afterEach(() => wrapper.unmount());
+
+ it('wraps a SearchBar, ShortUrlsList as Paginator', () => {
+ expect(wrapper.find(SearchBar)).toHaveLength(1);
+ expect(wrapper.find(ShortUrlsList)).toHaveLength(1);
+ expect(wrapper.find(Paginator)).toHaveLength(1);
+ });
+});
diff --git a/test/short-urls/helpers/CreateShortUrlResult.test.js b/test/short-urls/helpers/CreateShortUrlResult.test.js
new file mode 100644
index 00000000..b4ff4b50
--- /dev/null
+++ b/test/short-urls/helpers/CreateShortUrlResult.test.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { identity } from 'ramda';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { Tooltip } from 'reactstrap';
+import CreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
+
+describe('
', () => {
+ let wrapper;
+ const createWrapper = (result, error = false) => {
+ wrapper = shallow(
);
+
+ return wrapper;
+ };
+
+ afterEach(() => wrapper && wrapper.unmount());
+
+ it('renders an error when error is true', () => {
+ const wrapper = createWrapper({}, true);
+ const errorCard = wrapper.find('.bg-danger');
+
+ expect(errorCard).toHaveLength(1);
+ expect(errorCard.html()).toContain('An error occurred while creating the URL :(');
+ });
+
+ it('renders nothing when no result is provided', () => {
+ const wrapper = createWrapper();
+
+ expect(wrapper.html()).toBeNull();
+ });
+
+ it('renders a result message when result is provided', () => {
+ const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
+
+ expect(wrapper.html()).toContain('
Great! The short URL is
https://doma.in/abc123');
+ expect(wrapper.find(CopyToClipboard)).toHaveLength(1);
+ expect(wrapper.find(Tooltip)).toHaveLength(1);
+ });
+
+ it('Shows tooltip when copy to clipboard button is clicked', () => {
+ const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
+ const copyBtn = wrapper.find(CopyToClipboard);
+
+ expect(wrapper.state('showCopyTooltip')).toEqual(false);
+ copyBtn.simulate('copy');
+ expect(wrapper.state('showCopyTooltip')).toEqual(true);
+ });
+});
diff --git a/test/short-urls/helpers/DeleteShortUrlModal.test.js b/test/short-urls/helpers/DeleteShortUrlModal.test.js
new file mode 100644
index 00000000..fdc115a2
--- /dev/null
+++ b/test/short-urls/helpers/DeleteShortUrlModal.test.js
@@ -0,0 +1,115 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { identity } from 'ramda';
+import * as sinon from 'sinon';
+import { DeleteShortUrlModalComponent as DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal';
+
+describe('
', () => {
+ let wrapper;
+ const shortUrl = {
+ tags: [],
+ shortCode: 'abc123',
+ originalUrl: 'https://long-domain.com/foo/bar',
+ };
+ const deleteShortUrl = sinon.fake.returns(Promise.resolve());
+ const createWrapper = (shortUrlDeletion) => {
+ wrapper = shallow(
+
+ );
+
+ return wrapper;
+ };
+
+ afterEach(() => {
+ wrapper && wrapper.unmount();
+ deleteShortUrl.resetHistory();
+ });
+
+ it('shows threshold error message when threshold error occurs', () => {
+ const wrapper = createWrapper({
+ loading: false,
+ error: true,
+ shortCode: 'abc123',
+ errorData: { error: 'INVALID_SHORTCODE_DELETION' },
+ });
+ 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');
+ });
+
+ it('shows generic error when non-threshold error occurs', () => {
+ const wrapper = createWrapper({
+ loading: false,
+ error: true,
+ shortCode: 'abc123',
+ errorData: { error: 'OTHER_ERROR' },
+ });
+ const error = wrapper.find('.bg-danger');
+
+ expect(error).toHaveLength(1);
+ expect(error.html()).toContain('Something went wrong while deleting the URL :(');
+ });
+
+ it('disables submit button when loading', () => {
+ const wrapper = createWrapper({
+ loading: true,
+ error: false,
+ shortCode: 'abc123',
+ errorData: {},
+ });
+ const submit = wrapper.find('.btn-danger');
+
+ expect(submit).toHaveLength(1);
+ expect(submit.prop('disabled')).toEqual(true);
+ expect(submit.html()).toContain('Deleting...');
+ });
+
+ it('enables submit button when proper short code is provided', (done) => {
+ const shortCode = 'abc123';
+ const wrapper = createWrapper({
+ loading: false,
+ error: false,
+ shortCode,
+ errorData: {},
+ });
+ const input = wrapper.find('.form-control');
+
+ input.simulate('change', { target: { value: shortCode } });
+ setImmediate(() => {
+ const submit = wrapper.find('.btn-danger');
+
+ expect(submit.prop('disabled')).toEqual(false);
+ done();
+ });
+ });
+
+ it('tries to delete short URL when form is submit', (done) => {
+ const shortCode = 'abc123';
+ const wrapper = createWrapper({
+ loading: false,
+ error: false,
+ shortCode,
+ errorData: {},
+ });
+ const input = wrapper.find('.form-control');
+
+ input.simulate('change', { target: { value: shortCode } });
+ setImmediate(() => {
+ const form = wrapper.find('form');
+
+ expect(deleteShortUrl.callCount).toEqual(0);
+ form.simulate('submit', { preventDefault: identity });
+ expect(deleteShortUrl.callCount).toEqual(1);
+ done();
+ });
+ });
+});
diff --git a/test/short-urls/helpers/PreviewModal.test.js b/test/short-urls/helpers/PreviewModal.test.js
new file mode 100644
index 00000000..694eb0f7
--- /dev/null
+++ b/test/short-urls/helpers/PreviewModal.test.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
+import ExternalLink from '../../../src/utils/ExternalLink';
+
+describe('
', () => {
+ let wrapper;
+ const url = 'https://doma.in/abc123';
+
+ beforeEach(() => {
+ wrapper = shallow(
);
+ });
+ afterEach(() => wrapper.unmount());
+
+ it('shows an external link to the URL', () => {
+ const externalLink = wrapper.find(ExternalLink);
+
+ expect(externalLink).toHaveLength(1);
+ expect(externalLink.prop('href')).toEqual(url);
+ });
+
+ it('displays an image with the preview of the URL', () => {
+ const img = wrapper.find('img');
+
+ expect(img).toHaveLength(1);
+ expect(img.prop('src')).toEqual(`${url}/preview`);
+ });
+});
diff --git a/test/short-urls/helpers/QrCodeModal.test.js b/test/short-urls/helpers/QrCodeModal.test.js
new file mode 100644
index 00000000..5999cf51
--- /dev/null
+++ b/test/short-urls/helpers/QrCodeModal.test.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
+import ExternalLink from '../../../src/utils/ExternalLink';
+
+describe('
', () => {
+ let wrapper;
+ const url = 'https://doma.in/abc123';
+
+ beforeEach(() => {
+ wrapper = shallow(
);
+ });
+ afterEach(() => wrapper.unmount());
+
+ it('shows an external link to the URL', () => {
+ const externalLink = wrapper.find(ExternalLink);
+
+ expect(externalLink).toHaveLength(1);
+ expect(externalLink.prop('href')).toEqual(url);
+ });
+
+ it('displays an image with the QR code of the URL', () => {
+ const img = wrapper.find('img');
+
+ expect(img).toHaveLength(1);
+ expect(img.prop('src')).toEqual(`${url}/qr-code`);
+ });
+});
diff --git a/test/short-urls/reducers/shortUrlCreation.test.js b/test/short-urls/reducers/shortUrlCreation.test.js
new file mode 100644
index 00000000..ebf1dc04
--- /dev/null
+++ b/test/short-urls/reducers/shortUrlCreation.test.js
@@ -0,0 +1,94 @@
+import * as sinon from 'sinon';
+import reducer, {
+ CREATE_SHORT_URL_START,
+ CREATE_SHORT_URL_ERROR,
+ CREATE_SHORT_URL,
+ RESET_CREATE_SHORT_URL,
+ _createShortUrl,
+ resetCreateShortUrl,
+} from '../../../src/short-urls/reducers/shortUrlCreation';
+
+describe('shortUrlCreationReducer', () => {
+ describe('reducer', () => {
+ it('returns loading on CREATE_SHORT_URL_START', () => {
+ expect(reducer({}, { type: CREATE_SHORT_URL_START })).toEqual({
+ saving: true,
+ error: false,
+ });
+ });
+
+ it('returns error on CREATE_SHORT_URL_ERROR', () => {
+ expect(reducer({}, { type: CREATE_SHORT_URL_ERROR })).toEqual({
+ saving: false,
+ error: true,
+ });
+ });
+
+ it('returns result on CREATE_SHORT_URL', () => {
+ expect(reducer({}, { type: CREATE_SHORT_URL, result: 'foo' })).toEqual({
+ saving: false,
+ error: false,
+ result: 'foo',
+ });
+ });
+
+ it('returns default state on RESET_CREATE_SHORT_URL', () => {
+ expect(reducer({}, { type: RESET_CREATE_SHORT_URL })).toEqual({
+ result: null,
+ saving: false,
+ error: false,
+ });
+ });
+
+ it('returns provided state on unknown action', () =>
+ expect(reducer({}, { type: 'unknown' })).toEqual({}));
+ });
+
+ describe('resetCreateShortUrl', () => {
+ it('returns proper action', () =>
+ expect(resetCreateShortUrl()).toEqual({ type: RESET_CREATE_SHORT_URL }));
+ });
+
+ describe('createShortUrl', () => {
+ const createApiClientMock = (result) => ({
+ createShortUrl: sinon.fake.returns(result),
+ });
+ const dispatch = sinon.spy();
+
+ afterEach(() => dispatch.resetHistory());
+
+ it('calls API on success', async () => {
+ const expectedDispatchCalls = 2;
+ const result = 'foo';
+ const apiClientMock = createApiClientMock(Promise.resolve(result));
+ const dispatchable = _createShortUrl(apiClientMock, {});
+
+ await dispatchable(dispatch);
+
+ expect(apiClientMock.createShortUrl.callCount).toEqual(1);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL, result }]);
+ });
+
+ it('throws on error', async () => {
+ const expectedDispatchCalls = 2;
+ const error = 'Error';
+ const apiClientMock = createApiClientMock(Promise.reject(error));
+ const dispatchable = _createShortUrl(apiClientMock, {});
+
+ try {
+ await dispatchable(dispatch);
+ } catch (e) {
+ expect(e).toEqual(error);
+ }
+
+ expect(apiClientMock.createShortUrl.callCount).toEqual(1);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL_ERROR }]);
+ });
+ });
+});
diff --git a/test/tags/TagCard.test.js b/test/tags/TagCard.test.js
new file mode 100644
index 00000000..a905b07e
--- /dev/null
+++ b/test/tags/TagCard.test.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { Link } from 'react-router-dom';
+import TagCard from '../../src/tags/TagCard';
+import TagBullet from '../../src/tags/helpers/TagBullet';
+
+describe('
', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow(
);
+ });
+ afterEach(() => wrapper.unmount());
+
+ it('shows a TagBullet and a link to the list filtering by the tag', () => {
+ const link = wrapper.find(Link);
+ const bullet = wrapper.find(TagBullet);
+
+ expect(link.prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
+ expect(bullet.prop('tag')).toEqual('ssr');
+ });
+
+ it('displays delete modal when delete btn is clicked', (done) => {
+ const delBtn = wrapper.find('.tag-card__btn').at(0);
+
+ expect(wrapper.state('isDeleteModalOpen')).toEqual(false);
+ delBtn.simulate('click');
+
+ setImmediate(() => {
+ expect(wrapper.state('isDeleteModalOpen')).toEqual(true);
+ done();
+ });
+ });
+
+ it('displays edit modal when edit btn is clicked', (done) => {
+ const editBtn = wrapper.find('.tag-card__btn').at(1);
+
+ expect(wrapper.state('isEditModalOpen')).toEqual(false);
+ editBtn.simulate('click');
+
+ setImmediate(() => {
+ expect(wrapper.state('isEditModalOpen')).toEqual(true);
+ done();
+ });
+ });
+});
diff --git a/test/tags/TagsList.test.js b/test/tags/TagsList.test.js
new file mode 100644
index 00000000..bf37bd4f
--- /dev/null
+++ b/test/tags/TagsList.test.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { identity, range } from 'ramda';
+import * as sinon from 'sinon';
+import { TagsListComponent as TagsList } from '../../src/tags/TagsList';
+import MuttedMessage from '../../src/utils/MuttedMessage';
+import TagCard from '../../src/tags/TagCard';
+import SearchField from '../../src/utils/SearchField';
+
+describe('
', () => {
+ let wrapper;
+ const filterTags = sinon.spy();
+ const createWrapper = (tagsList) => {
+ const params = { serverId: '1' };
+
+ wrapper = shallow(
+
+ );
+
+ return wrapper;
+ };
+
+ afterEach(() => {
+ wrapper && wrapper.unmount();
+ filterTags.resetHistory();
+ });
+
+ it('shows a loading message when tags are being loaded', () => {
+ const wrapper = createWrapper({ loading: true });
+ const loadingMsg = wrapper.find(MuttedMessage);
+
+ expect(loadingMsg).toHaveLength(1);
+ expect(loadingMsg.html()).toContain('Loading...');
+ });
+
+ it('shows an error when tags failed to be loaded', () => {
+ const wrapper = createWrapper({ error: true });
+ const errorMsg = wrapper.find('.bg-danger');
+
+ expect(errorMsg).toHaveLength(1);
+ expect(errorMsg.html()).toContain('Error loading tags :(');
+ });
+
+ it('shows a message when the list of tags is empty', () => {
+ const wrapper = createWrapper({ filteredTags: [] });
+ const msg = wrapper.find(MuttedMessage);
+
+ expect(msg).toHaveLength(1);
+ expect(msg.html()).toContain('No tags found');
+ });
+
+ it('renders the proper amount of groups and cards based on the amount of tags', () => {
+ const amountOfTags = 10;
+ const amountOfGroups = 4;
+ const wrapper = createWrapper({ filteredTags: range(0, amountOfTags).map((i) => `tag_${i}`) });
+ const cards = wrapper.find(TagCard);
+ const groups = wrapper.find('.col-md-6');
+
+ expect(cards).toHaveLength(amountOfTags);
+ expect(groups).toHaveLength(amountOfGroups);
+ });
+
+ it('triggers tags filtering when search field changes', (done) => {
+ const wrapper = createWrapper({ filteredTags: [] });
+ const searchField = wrapper.find(SearchField);
+
+ expect(searchField).toHaveLength(1);
+ expect(filterTags.callCount).toEqual(0);
+ searchField.simulate('change');
+
+ setImmediate(() => {
+ expect(filterTags.callCount).toEqual(1);
+ done();
+ });
+ });
+});
diff --git a/test/tags/reducers/tagDelete.test.js b/test/tags/reducers/tagDelete.test.js
new file mode 100644
index 00000000..ece3c92d
--- /dev/null
+++ b/test/tags/reducers/tagDelete.test.js
@@ -0,0 +1,91 @@
+import * as sinon from 'sinon';
+import reducer, {
+ DELETE_TAG_START,
+ DELETE_TAG_ERROR,
+ DELETE_TAG,
+ TAG_DELETED,
+ tagDeleted,
+ _deleteTag,
+} from '../../../src/tags/reducers/tagDelete';
+
+describe('tagDeleteReducer', () => {
+ describe('reducer', () => {
+ it('returns loading on DELETE_TAG_START', () => {
+ expect(reducer({}, { type: DELETE_TAG_START })).toEqual({
+ deleting: true,
+ error: false,
+ });
+ });
+
+ it('returns error on DELETE_TAG_ERROR', () => {
+ expect(reducer({}, { type: DELETE_TAG_ERROR })).toEqual({
+ deleting: false,
+ error: true,
+ });
+ });
+
+ it('returns tag names on DELETE_TAG', () => {
+ expect(reducer({}, { type: DELETE_TAG })).toEqual({
+ deleting: false,
+ error: false,
+ });
+ });
+
+ it('returns provided state on unknown action', () =>
+ expect(reducer({}, { type: 'unknown' })).toEqual({}));
+ });
+
+ describe('tagDeleted', () => {
+ it('returns action based on provided params', () =>
+ expect(tagDeleted('foo')).toEqual({
+ type: TAG_DELETED,
+ tag: 'foo',
+ }));
+ });
+
+ describe('deleteTag', () => {
+ const createApiClientMock = (result) => ({
+ deleteTags: sinon.fake.returns(result),
+ });
+ const dispatch = sinon.spy();
+
+ afterEach(() => dispatch.resetHistory());
+
+ it('calls API on success', async () => {
+ const expectedDispatchCalls = 2;
+ const tag = 'foo';
+ const apiClientMock = createApiClientMock(Promise.resolve());
+ const dispatchable = _deleteTag(apiClientMock, tag);
+
+ await dispatchable(dispatch);
+
+ expect(apiClientMock.deleteTags.callCount).toEqual(1);
+ expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG }]);
+ });
+
+ it('throws on error', async () => {
+ const expectedDispatchCalls = 2;
+ const error = 'Error';
+ const tag = 'foo';
+ const apiClientMock = createApiClientMock(Promise.reject(error));
+ const dispatchable = _deleteTag(apiClientMock, tag);
+
+ try {
+ await dispatchable(dispatch);
+ } catch (e) {
+ expect(e).toEqual(error);
+ }
+
+ expect(apiClientMock.deleteTags.callCount).toEqual(1);
+ expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG_ERROR }]);
+ });
+ });
+});
diff --git a/test/tags/reducers/tagEdit.test.js b/test/tags/reducers/tagEdit.test.js
new file mode 100644
index 00000000..8a1ca80c
--- /dev/null
+++ b/test/tags/reducers/tagEdit.test.js
@@ -0,0 +1,110 @@
+import * as sinon from 'sinon';
+import reducer, {
+ EDIT_TAG_START,
+ EDIT_TAG_ERROR,
+ EDIT_TAG,
+ TAG_EDITED,
+ tagEdited,
+ _editTag,
+} from '../../../src/tags/reducers/tagEdit';
+
+describe('tagEditReducer', () => {
+ describe('reducer', () => {
+ it('returns loading on EDIT_TAG_START', () => {
+ expect(reducer({}, { type: EDIT_TAG_START })).toEqual({
+ editing: true,
+ error: false,
+ });
+ });
+
+ it('returns error on EDIT_TAG_ERROR', () => {
+ expect(reducer({}, { type: EDIT_TAG_ERROR })).toEqual({
+ editing: false,
+ error: true,
+ });
+ });
+
+ it('returns tag names on EDIT_TAG', () => {
+ expect(reducer({}, { type: EDIT_TAG, oldName: 'foo', newName: 'bar' })).toEqual({
+ editing: false,
+ error: false,
+ oldName: 'foo',
+ newName: 'bar',
+ });
+ });
+
+ it('returns provided state on unknown action', () =>
+ expect(reducer({}, { type: 'unknown' })).toEqual({}));
+ });
+
+ describe('tagEdited', () => {
+ it('returns action based on provided params', () =>
+ expect(tagEdited('foo', 'bar', '#ff0000')).toEqual({
+ type: TAG_EDITED,
+ oldName: 'foo',
+ newName: 'bar',
+ color: '#ff0000',
+ }));
+ });
+
+ describe('editTag', () => {
+ const createApiClientMock = (result) => ({
+ editTag: sinon.fake.returns(result),
+ });
+ const colorGenerator = {
+ setColorForKey: sinon.spy(),
+ };
+ const dispatch = sinon.spy();
+
+ afterEach(() => {
+ colorGenerator.setColorForKey.resetHistory();
+ dispatch.resetHistory();
+ });
+
+ it('calls API on success', async () => {
+ const expectedDispatchCalls = 2;
+ const oldName = 'foo';
+ const newName = 'bar';
+ const color = '#ff0000';
+ const apiClientMock = createApiClientMock(Promise.resolve());
+ const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
+
+ await dispatchable(dispatch);
+
+ expect(apiClientMock.editTag.callCount).toEqual(1);
+ expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
+
+ expect(colorGenerator.setColorForKey.callCount).toEqual(1);
+ expect(colorGenerator.setColorForKey.getCall(0).args).toEqual([ newName, color ]);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG, oldName, newName }]);
+ });
+
+ it('throws on error', async () => {
+ const expectedDispatchCalls = 2;
+ const error = 'Error';
+ const oldName = 'foo';
+ const newName = 'bar';
+ const color = '#ff0000';
+ const apiClientMock = createApiClientMock(Promise.reject(error));
+ const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
+
+ try {
+ await dispatchable(dispatch);
+ } catch (e) {
+ expect(e).toEqual(error);
+ }
+
+ expect(apiClientMock.editTag.callCount).toEqual(1);
+ expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
+
+ expect(colorGenerator.setColorForKey.callCount).toEqual(0);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG_ERROR }]);
+ });
+ });
+});
diff --git a/test/common/DateInput.test.js b/test/utils/DateInput.test.js
similarity index 85%
rename from test/common/DateInput.test.js
rename to test/utils/DateInput.test.js
index 6ec0bbbd..172713ae 100644
--- a/test/common/DateInput.test.js
+++ b/test/utils/DateInput.test.js
@@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import moment from 'moment';
-import DateInput from '../../src/common/DateInput';
+import DateInput from '../../src/utils/DateInput';
describe('
', () => {
let wrapped;
@@ -13,12 +13,7 @@ describe('
', () => {
return wrapped;
};
- afterEach(() => {
- if (wrapped !== undefined) {
- wrapped.unmount();
- wrapped = undefined;
- }
- });
+ afterEach(() => wrapped && wrapped.unmount());
it('wrapps a DatePicker', () => {
wrapped = createComponent();
diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js
index f2f236e3..f103987d 100644
--- a/test/visits/ShortUrlVisits.test.js
+++ b/test/visits/ShortUrlVisits.test.js
@@ -6,7 +6,7 @@ import * as sinon from 'sinon';
import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits';
import MutedMessage from '../../src/utils/MuttedMessage';
import GraphCard from '../../src/visits/GraphCard';
-import DateInput from '../../src/common/DateInput';
+import DateInput from '../../src/utils/DateInput';
import SortableBarGraph from '../../src/visits/SortableBarGraph';
describe('
', () => {