diff --git a/indocker b/indocker
index ede86328..81147641 100755
--- a/indocker
+++ b/indocker
@@ -1,2 +1,8 @@
#!/usr/bin/env bash
+
+# Run docker container if it's not up yet
+if ! [[ $(docker ps | grep shlink_web_client_node) ]]; then
+ docker-compose up -d
+fi
+
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"
diff --git a/jest.config.js b/jest.config.js
index a7bdef22..b3126e32 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -4,6 +4,7 @@ module.exports = {
'src/**/*.js',
'!src/registerServiceWorker.js',
'!src/index.js',
+ '!src/reducers/index.js',
'!src/**/provideServices.js',
'!src/container/*.js',
],
diff --git a/src/common/ScrollToTop.js b/src/common/ScrollToTop.js
index c8624da0..19a34dfb 100644
--- a/src/common/ScrollToTop.js
+++ b/src/common/ScrollToTop.js
@@ -1,20 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
-export default class ScrollToTop extends React.Component {
+const ScrollToTop = (window) => class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
- window: PropTypes.shape({
- scrollTo: PropTypes.func,
- }),
children: PropTypes.node,
};
- static defaultProps = {
- window: global.window,
- };
componentDidUpdate(prevProps) {
- const { location, window } = this.props;
+ const { location } = this.props;
if (location !== prevProps.location) {
window.scrollTo(0, 0);
@@ -24,4 +18,6 @@ export default class ScrollToTop extends React.Component {
render() {
return this.props.children;
}
-}
+};
+
+export default ScrollToTop;
diff --git a/src/common/services/provideServices.js b/src/common/services/provideServices.js
index 584ddcf9..eb7e0c9d 100644
--- a/src/common/services/provideServices.js
+++ b/src/common/services/provideServices.js
@@ -5,7 +5,9 @@ import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
const provideServices = (bottle, connect, withRouter) => {
- bottle.constant('ScrollToTop', ScrollToTop);
+ bottle.constant('window', global.window);
+
+ bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
bottle.decorator('ScrollToTop', withRouter);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
diff --git a/src/servers/services/provideServices.js b/src/servers/services/provideServices.js
index a2464749..c12f1b35 100644
--- a/src/servers/services/provideServices.js
+++ b/src/servers/services/provideServices.js
@@ -29,7 +29,6 @@ const provideServices = (bottle, connect, withRouter) => {
// Services
bottle.constant('csvjson', csvjson);
- bottle.constant('window', global.window);
bottle.service('ServersImporter', ServersImporter, 'csvjson');
bottle.service('ServersService', ServersService, 'Storage');
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
diff --git a/src/short-urls/helpers/EditTagsModal.js b/src/short-urls/helpers/EditTagsModal.js
index d98214ec..f5d34dd2 100644
--- a/src/short-urls/helpers/EditTagsModal.js
+++ b/src/short-urls/helpers/EditTagsModal.js
@@ -1,8 +1,8 @@
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
-import { shortUrlTagsType } from '../reducers/shortUrlTags';
import ExternalLink from '../../utils/ExternalLink';
+import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList';
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
diff --git a/src/short-urls/reducers/shortUrlDeletion.js b/src/short-urls/reducers/shortUrlDeletion.js
index aeb46ed5..6d2f2f52 100644
--- a/src/short-urls/reducers/shortUrlDeletion.js
+++ b/src/short-urls/reducers/shortUrlDeletion.js
@@ -1,10 +1,10 @@
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements, newline-after-var */
-const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
-const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
-const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
-const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
+export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
+export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
+export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
+export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
@@ -58,10 +58,10 @@ export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (di
dispatch({ type: DELETE_SHORT_URL_START });
const { selectedServer } = getState();
- const shlinkApiClient = buildShlinkApiClient(selectedServer);
+ const { deleteShortUrl } = buildShlinkApiClient(selectedServer);
try {
- await shlinkApiClient.deleteShortUrl(shortCode);
+ await deleteShortUrl(shortCode);
dispatch({ type: DELETE_SHORT_URL, shortCode });
} catch (e) {
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js
index eec4983f..ad972cea 100644
--- a/src/short-urls/reducers/shortUrlsList.js
+++ b/src/short-urls/reducers/shortUrlsList.js
@@ -1,11 +1,11 @@
-import { assoc, assocPath, reject } from 'ramda';
+import { assoc, assocPath, propEq, reject } from 'ramda';
import PropTypes from 'prop-types';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
/* eslint-disable padding-line-between-statements, newline-after-var */
-const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
-const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
+export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
+export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
/* eslint-enable padding-line-between-statements, newline-after-var */
@@ -18,6 +18,7 @@ export const shortUrlType = PropTypes.shape({
const initialState = {
shortUrls: {},
loading: true,
+ error: false,
};
export default function reducer(state = initialState, action) {
@@ -34,7 +35,7 @@ export default function reducer(state = initialState, action) {
return {
loading: false,
error: true,
- shortUrls: [],
+ shortUrls: {},
};
case SHORT_URL_TAGS_EDITED:
const { data } = state.shortUrls;
@@ -46,7 +47,7 @@ export default function reducer(state = initialState, action) {
case SHORT_URL_DELETED:
return assocPath(
[ 'shortUrls', 'data' ],
- reject((shortUrl) => shortUrl.shortCode === action.shortCode, state.shortUrls.data),
+ reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
state,
);
default:
@@ -58,10 +59,10 @@ export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (d
dispatch({ type: LIST_SHORT_URLS_START });
const { selectedServer = {} } = getState();
- const shlinkApiClient = buildShlinkApiClient(selectedServer);
+ const { listShortUrls } = buildShlinkApiClient(selectedServer);
try {
- const shortUrls = await shlinkApiClient.listShortUrls(params);
+ const shortUrls = await listShortUrls(params);
dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
} catch (e) {
diff --git a/test/short-urls/helpers/EditTagsModal.test.js b/test/short-urls/helpers/EditTagsModal.test.js
new file mode 100644
index 00000000..3c307eba
--- /dev/null
+++ b/test/short-urls/helpers/EditTagsModal.test.js
@@ -0,0 +1,151 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import * as sinon from 'sinon';
+import { Modal } from 'reactstrap';
+import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
+
+describe('', () => {
+ let wrapper;
+ const shortCode = 'abc123';
+ const TagsSelector = () => '';
+ const editShortUrlTags = sinon.fake.resolves();
+ const shortUrlTagsEdited = sinon.fake();
+ const resetShortUrlsTags = sinon.fake();
+ const toggle = sinon.fake();
+ const createWrapper = (shortUrlTags) => {
+ const EditTagsModal = createEditTagsModal(TagsSelector);
+
+ wrapper = shallow(
+
+ );
+
+ return wrapper;
+ };
+
+ afterEach(() => {
+ wrapper && wrapper.unmount();
+ editShortUrlTags.resetHistory();
+ shortUrlTagsEdited.resetHistory();
+ resetShortUrlsTags.resetHistory();
+ toggle.resetHistory();
+ });
+
+ it('resets tags when component is mounted', () => {
+ createWrapper({
+ shortCode,
+ tags: [],
+ saving: false,
+ error: false,
+ });
+
+ expect(resetShortUrlsTags.callCount).toEqual(1);
+ });
+
+ it('renders tags selector and save button when loaded', () => {
+ const wrapper = createWrapper({
+ shortCode,
+ tags: [],
+ saving: false,
+ error: false,
+ });
+ const saveBtn = wrapper.find('.btn-primary');
+
+ expect(wrapper.find(TagsSelector)).toHaveLength(1);
+ expect(saveBtn.prop('disabled')).toBe(false);
+ expect(saveBtn.text()).toEqual('Save tags');
+ });
+
+ it('disables save button when saving is in progress', () => {
+ const wrapper = createWrapper({
+ shortCode,
+ tags: [],
+ saving: true,
+ error: false,
+ });
+ const saveBtn = wrapper.find('.btn-primary');
+
+ expect(saveBtn.prop('disabled')).toBe(true);
+ expect(saveBtn.text()).toEqual('Saving tags...');
+ });
+
+ it('saves tags when save button is clicked', (done) => {
+ const wrapper = createWrapper({
+ shortCode,
+ tags: [],
+ saving: true,
+ error: false,
+ });
+ const saveBtn = wrapper.find('.btn-primary');
+
+ saveBtn.simulate('click');
+
+ expect(editShortUrlTags.callCount).toEqual(1);
+ expect(editShortUrlTags.getCall(0).args).toEqual([ shortCode, []]);
+
+ // Wrap this expect in a setImmediate since it is called as a result of an inner promise
+ setImmediate(() => {
+ expect(toggle.callCount).toEqual(1);
+ done();
+ });
+ });
+
+ it('does not notify tags have been edited when window is closed without saving', () => {
+ const wrapper = createWrapper({
+ shortCode,
+ tags: [],
+ saving: false,
+ error: false,
+ });
+ const modal = wrapper.find(Modal);
+
+ modal.simulate('closed');
+ expect(shortUrlTagsEdited.callCount).toEqual(0);
+ });
+
+ it('notifies tags have been edited when window is closed after saving', (done) => {
+ const wrapper = createWrapper({
+ shortCode,
+ tags: [],
+ saving: true,
+ error: false,
+ });
+ const saveBtn = wrapper.find('.btn-primary');
+ const modal = wrapper.find(Modal);
+
+ saveBtn.simulate('click');
+
+ // Wrap this expect in a setImmediate since it is called as a result of an inner promise
+ setImmediate(() => {
+ modal.simulate('closed');
+ expect(shortUrlTagsEdited.callCount).toEqual(1);
+ expect(shortUrlTagsEdited.getCall(0).args).toEqual([ shortCode, []]);
+ done();
+ });
+ });
+
+ it('toggles modal when cancel button is clicked', () => {
+ const wrapper = createWrapper({
+ shortCode,
+ tags: [],
+ saving: true,
+ error: false,
+ });
+ const cancelBtn = wrapper.find('.btn-link');
+
+ cancelBtn.simulate('click');
+ expect(toggle.callCount).toEqual(1);
+ });
+});
diff --git a/test/short-urls/reducers/shortUrlDeleteion.test.js b/test/short-urls/reducers/shortUrlDeleteion.test.js
new file mode 100644
index 00000000..60aca8e7
--- /dev/null
+++ b/test/short-urls/reducers/shortUrlDeleteion.test.js
@@ -0,0 +1,115 @@
+import * as sinon from 'sinon';
+import reducer, {
+ DELETE_SHORT_URL, DELETE_SHORT_URL_ERROR,
+ DELETE_SHORT_URL_START,
+ RESET_DELETE_SHORT_URL,
+ SHORT_URL_DELETED,
+ resetDeleteShortUrl,
+ shortUrlDeleted,
+ deleteShortUrl,
+} from '../../../src/short-urls/reducers/shortUrlDeletion';
+
+describe('shortUrlDeletionReducer', () => {
+ describe('reducer', () => {
+ it('returns loading on DELETE_SHORT_URL_START', () =>
+ expect(reducer(undefined, { type: DELETE_SHORT_URL_START })).toEqual({
+ shortCode: '',
+ loading: true,
+ error: false,
+ errorData: {},
+ }));
+
+ it('returns default on RESET_DELETE_SHORT_URL', () =>
+ expect(reducer(undefined, { type: RESET_DELETE_SHORT_URL })).toEqual({
+ shortCode: '',
+ loading: false,
+ error: false,
+ errorData: {},
+ }));
+
+ it('returns shortCode on DELETE_SHORT_URL', () =>
+ expect(reducer(undefined, { type: DELETE_SHORT_URL, shortCode: 'foo' })).toEqual({
+ shortCode: 'foo',
+ loading: false,
+ error: false,
+ errorData: {},
+ }));
+
+ it('returns errorData on DELETE_SHORT_URL_ERROR', () => {
+ const errorData = { foo: 'bar' };
+
+ expect(reducer(undefined, { type: DELETE_SHORT_URL_ERROR, errorData })).toEqual({
+ shortCode: '',
+ loading: false,
+ error: true,
+ errorData,
+ });
+ });
+
+ it('returns provided state as is on unknown action', () => {
+ const state = { foo: 'bar' };
+
+ expect(reducer(state, { type: 'unknown' })).toEqual(state);
+ });
+ });
+
+ describe('resetDeleteShortUrl', () => {
+ it('returns expected action', () =>
+ expect(resetDeleteShortUrl()).toEqual({ type: RESET_DELETE_SHORT_URL }));
+ });
+
+ describe('shortUrlDeleted', () => {
+ it('returns expected action', () =>
+ expect(shortUrlDeleted('abc123')).toEqual({ type: SHORT_URL_DELETED, shortCode: 'abc123' }));
+ });
+
+ describe('deleteShortUrl', () => {
+ const dispatch = sinon.spy();
+ const getState = sinon.fake.returns({ selectedServer: {} });
+
+ afterEach(() => {
+ dispatch.resetHistory();
+ getState.resetHistory();
+ });
+
+ it('dispatches proper actions if API client request succeeds', async () => {
+ const apiClientMock = {
+ deleteShortUrl: sinon.fake.resolves(''),
+ };
+ const shortCode = 'abc123';
+ const expectedDispatchCalls = 2;
+
+ await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_SHORT_URL_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_SHORT_URL, shortCode }]);
+
+ expect(apiClientMock.deleteShortUrl.callCount).toEqual(1);
+ expect(apiClientMock.deleteShortUrl.getCall(0).args).toEqual([ shortCode ]);
+ });
+
+ it('dispatches proper actions if API client request fails', async () => {
+ const data = { foo: 'bar' };
+ const error = { response: { data } };
+ const apiClientMock = {
+ deleteShortUrl: sinon.fake.returns(Promise.reject(error)),
+ };
+ const shortCode = 'abc123';
+ const expectedDispatchCalls = 2;
+
+ try {
+ await deleteShortUrl(() => apiClientMock)(shortCode)(dispatch, getState);
+ } catch (e) {
+ expect(e).toEqual(error);
+ }
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_SHORT_URL_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_SHORT_URL_ERROR, errorData: data }]);
+
+ expect(apiClientMock.deleteShortUrl.callCount).toEqual(1);
+ expect(apiClientMock.deleteShortUrl.getCall(0).args).toEqual([ shortCode ]);
+ });
+ });
+});
diff --git a/test/short-urls/reducers/shortUrlsList.test.js b/test/short-urls/reducers/shortUrlsList.test.js
new file mode 100644
index 00000000..08274afb
--- /dev/null
+++ b/test/short-urls/reducers/shortUrlsList.test.js
@@ -0,0 +1,120 @@
+import * as sinon from 'sinon';
+import reducer, {
+ LIST_SHORT_URLS,
+ LIST_SHORT_URLS_ERROR,
+ LIST_SHORT_URLS_START,
+ listShortUrls,
+} from '../../../src/short-urls/reducers/shortUrlsList';
+import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
+import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
+
+describe('shortUrlsListReducer', () => {
+ describe('reducer', () => {
+ it('returns loading on LIST_SHORT_URLS_START', () =>
+ expect(reducer(undefined, { type: LIST_SHORT_URLS_START })).toEqual({
+ shortUrls: {},
+ loading: true,
+ error: false,
+ }));
+
+ it('returns short URLs on LIST_SHORT_URLS', () =>
+ expect(reducer(undefined, { type: LIST_SHORT_URLS, shortUrls: { data: [], paginator: {} } })).toEqual({
+ shortUrls: { data: [], paginator: {} },
+ loading: false,
+ error: false,
+ }));
+
+ it('returns error on LIST_SHORT_URLS_ERROR', () =>
+ expect(reducer(undefined, { type: LIST_SHORT_URLS_ERROR })).toEqual({
+ shortUrls: {},
+ loading: false,
+ error: true,
+ }));
+
+ it('Updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => {
+ const shortCode = 'abc123';
+ const tags = [ 'foo', 'bar', 'baz' ];
+ const state = {
+ shortUrls: {
+ data: [
+ { shortCode, tags: [] },
+ { shortCode: 'foo', tags: [] },
+ ],
+ },
+ };
+
+ expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags })).toEqual({
+ shortUrls: {
+ data: [
+ { shortCode, tags },
+ { shortCode: 'foo', tags: [] },
+ ],
+ },
+ });
+ });
+
+ it('Removes matching URL on SHORT_URL_DELETED', () => {
+ const shortCode = 'abc123';
+ const state = {
+ shortUrls: {
+ data: [
+ { shortCode },
+ { shortCode: 'foo' },
+ ],
+ },
+ };
+
+ expect(reducer(state, { type: SHORT_URL_DELETED, shortCode })).toEqual({
+ shortUrls: {
+ data: [{ shortCode: 'foo' }],
+ },
+ });
+ });
+
+ it('returns provided state as is on unknown action', () => {
+ const state = { foo: 'bar' };
+
+ expect(reducer(state, { type: 'unknown' })).toEqual(state);
+ });
+ });
+
+ describe('listShortUrls', () => {
+ const dispatch = sinon.spy();
+ const getState = sinon.fake.returns({ selectedServer: {} });
+
+ afterEach(() => {
+ dispatch.resetHistory();
+ getState.resetHistory();
+ });
+
+ it('dispatches proper actions if API client request succeeds', async () => {
+ const apiClientMock = {
+ listShortUrls: sinon.fake.resolves([]),
+ };
+ const expectedDispatchCalls = 2;
+
+ await listShortUrls(() => apiClientMock)()(dispatch, getState);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: LIST_SHORT_URLS_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: LIST_SHORT_URLS, shortUrls: [], params: {} }]);
+
+ expect(apiClientMock.listShortUrls.callCount).toEqual(1);
+ });
+
+ it('dispatches proper actions if API client request fails', async () => {
+ const apiClientMock = {
+ listShortUrls: sinon.fake.rejects(),
+ };
+ const expectedDispatchCalls = 2;
+
+ await listShortUrls(() => apiClientMock)()(dispatch, getState);
+
+ expect(dispatch.callCount).toEqual(expectedDispatchCalls);
+ expect(dispatch.getCall(0).args).toEqual([{ type: LIST_SHORT_URLS_START }]);
+ expect(dispatch.getCall(1).args).toEqual([{ type: LIST_SHORT_URLS_ERROR, params: {} }]);
+
+ expect(apiClientMock.listShortUrls.callCount).toEqual(1);
+ });
+ });
+});
diff --git a/test/utils/services/Storage.test.js b/test/utils/services/Storage.test.js
new file mode 100644
index 00000000..b244ffcb
--- /dev/null
+++ b/test/utils/services/Storage.test.js
@@ -0,0 +1,50 @@
+import * as sinon from 'sinon';
+import Storage from '../../../src/utils/services/Storage';
+
+describe('Storage', () => {
+ const localStorageMock = {
+ getItem: sinon.fake((key) => key === 'shlink.foo' ? JSON.stringify({ foo: 'bar' }) : null),
+ setItem: sinon.spy(),
+ };
+ let storage;
+
+ beforeEach(() => {
+ localStorageMock.getItem.resetHistory();
+ localStorageMock.setItem.resetHistory();
+
+ storage = new Storage(localStorageMock);
+ });
+
+ describe('set', () => {
+ it('writes an stringified representation of provided value in local storage', () => {
+ const value = { bar: 'baz' };
+
+ storage.set('foo', value);
+
+ expect(localStorageMock.setItem.callCount).toEqual(1);
+ expect(localStorageMock.setItem.getCall(0).args).toEqual([
+ 'shlink.foo',
+ JSON.stringify(value),
+ ]);
+ });
+ });
+
+ describe('get', () => {
+ it('fetches item from local storage', () => {
+ storage.get('foo');
+ expect(localStorageMock.getItem.callCount).toEqual(1);
+ });
+
+ it('returns parsed value when requested value is found in local storage', () => {
+ const value = storage.get('foo');
+
+ expect(value).toEqual({ foo: 'bar' });
+ });
+
+ it('returns undefined when requested value is not found in local storage', () => {
+ const value = storage.get('bar');
+
+ expect(value).toBeUndefined();
+ });
+ });
+});