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(); + }); + }); +});