diff --git a/src/common/DateInput.js b/src/common/DateInput.js deleted file mode 100644 index a6586b47..00000000 --- a/src/common/DateInput.js +++ /dev/null @@ -1,37 +0,0 @@ -import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt'; -import FontAwesomeIcon from '@fortawesome/react-fontawesome'; -import React from 'react'; -import DatePicker from 'react-datepicker'; -import { isNil } from 'ramda'; -import './DateInput.scss'; - -export default class DateInput extends React.Component { - constructor(props) { - super(props); - this.inputRef = props.ref || React.createRef(); - } - - render() { - const { className, isClearable, selected } = this.props; - const showCalendarIcon = !isClearable || isNil(selected); - - return ( -
- - {showCalendarIcon && ( - this.inputRef.current.input.focus()} - /> - )} -
- ); - } -} diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index e3c65819..5e0163f7 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -5,12 +5,22 @@ import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda'; import React from 'react'; import { connect } from 'react-redux'; import { Collapse } from 'reactstrap'; -import DateInput from '../common/DateInput'; +import * as PropTypes from 'prop-types'; +import DateInput from '../utils/DateInput'; import TagsSelector from '../tags/helpers/TagsSelector'; import CreateShortUrlResult from './helpers/CreateShortUrlResult'; -import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreation'; +import { createShortUrl, createShortUrlResultType, resetCreateShortUrl } from './reducers/shortUrlCreation'; + +const normalizeTag = pipe(trim, replace(/ /g, '-')); +const formatDate = (date) => isNil(date) ? date : date.format(); export class CreateShortUrlComponent extends React.Component { + static propTypes = { + createShortUrl: PropTypes.func, + shortUrlCreationResult: createShortUrlResultType, + resetCreateShortUrl: PropTypes.func, + }; + state = { longUrl: '', tags: [], @@ -24,27 +34,31 @@ export class CreateShortUrlComponent extends React.Component { render() { const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props; - const changeTags = (tags) => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) }); + const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) }); const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => ( - this.setState({ [id]: e.target.value })} - {...props} - /> +
+ this.setState({ [id]: e.target.value })} + {...props} + /> +
); - const createDateInput = (id, placeholder, props = {}) => ( - this.setState({ [id]: date })} - {...props} - /> + const renderDateInput = (id, placeholder, props = {}) => ( +
+ this.setState({ [id]: date })} + {...props} + /> +
); - const formatDate = (date) => isNil(date) ? date : date.format(); const save = (e) => { e.preventDefault(); createShortUrl(pipe( @@ -75,20 +89,12 @@ export class CreateShortUrlComponent extends React.Component {
-
- {renderOptionalInput('customSlug', 'Custom slug')} -
-
- {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} -
+ {renderOptionalInput('customSlug', 'Custom slug')} + {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
-
- {createDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })} -
-
- {createDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })} -
+ {renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })} + {renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 945923a1..9ebeb2d0 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -115,23 +115,17 @@ export class ShortUrlsListComponent extends React.Component { )); } - renderMobileOrderingControls() { - return ( -
- -
- ); - } - render() { return ( - {this.renderMobileOrderingControls()} +
+ +
diff --git a/src/short-urls/helpers/DeleteShortUrlModal.js b/src/short-urls/helpers/DeleteShortUrlModal.js index 7eca1e04..40bd790d 100644 --- a/src/short-urls/helpers/DeleteShortUrlModal.js +++ b/src/short-urls/helpers/DeleteShortUrlModal.js @@ -47,6 +47,8 @@ export class DeleteShortUrlModalComponent extends 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; return ( @@ -66,12 +68,12 @@ export class DeleteShortUrlModalComponent extends Component { onChange={(e) => this.setState({ inputValue: e.target.value })} /> - {shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED && ( + {hasThresholdError && (
This short URL has received too many visits and therefore, it cannot be deleted
)} - {shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED && ( + {hasErrorOtherThanThreshold && (
Something went wrong while deleting the URL :(
diff --git a/src/short-urls/helpers/PreviewModal.js b/src/short-urls/helpers/PreviewModal.js index 87e171aa..8496be7a 100644 --- a/src/short-urls/helpers/PreviewModal.js +++ b/src/short-urls/helpers/PreviewModal.js @@ -10,20 +10,20 @@ const propTypes = { isOpen: PropTypes.bool, }; -export default function PreviewModal({ url, toggle, isOpen }) { - return ( - - - Preview for {url} - - -
-

Loading...

- Preview -
-
-
- ); -} +const PreviewModal = ({ url, toggle, isOpen }) => ( + + + Preview for {url} + + +
+

Loading...

+ Preview +
+
+
+); PreviewModal.propTypes = propTypes; + +export default PreviewModal; diff --git a/src/short-urls/helpers/QrCodeModal.js b/src/short-urls/helpers/QrCodeModal.js index ee83b1df..3a8cd43f 100644 --- a/src/short-urls/helpers/QrCodeModal.js +++ b/src/short-urls/helpers/QrCodeModal.js @@ -10,19 +10,19 @@ const propTypes = { isOpen: PropTypes.bool, }; -export default function QrCodeModal({ url, toggle, isOpen }) { - return ( - - - QR code for {url} - - -
- QR code -
-
-
- ); -} +const QrCodeModal = ({ url, toggle, isOpen }) => ( + + + QR code for {url} + + +
+ QR code +
+
+
+); QrCodeModal.propTypes = propTypes; + +export default QrCodeModal; diff --git a/src/short-urls/reducers/shortUrlCreation.js b/src/short-urls/reducers/shortUrlCreation.js index ccb4f01c..9f01a8ee 100644 --- a/src/short-urls/reducers/shortUrlCreation.js +++ b/src/short-urls/reducers/shortUrlCreation.js @@ -3,10 +3,10 @@ import PropTypes from 'prop-types'; import shlinkApiClient from '../../api/ShlinkApiClient'; /* eslint-disable padding-line-between-statements, newline-after-var */ -const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; -const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR'; -const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; -const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL'; +export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; +export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR'; +export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; +export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL'; /* eslint-enable padding-line-between-statements, newline-after-var */ export const createShortUrlResultType = PropTypes.shape({ @@ -29,6 +29,7 @@ export default function reducer(state = defaultState, action) { return { ...state, saving: true, + error: false, }; case CREATE_SHORT_URL_ERROR: return { diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js index 8121e570..538a86ac 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -28,36 +28,20 @@ export default class TagCard extends React.Component { return ( - -
- - {tag} - + {tag}
- - + +
); } diff --git a/src/tags/TagsList.js b/src/tags/TagsList.js index b23accb3..1971817e 100644 --- a/src/tags/TagsList.js +++ b/src/tags/TagsList.js @@ -8,7 +8,7 @@ import { filterTags, forceListTags } from './reducers/tagsList'; import TagCard from './TagCard'; const { ceil } = Math; -const TAGS_GROUP_SIZE = 4; +const TAGS_GROUPS_AMOUNT = 4; export class TagsListComponent extends React.Component { static propTypes = { @@ -16,6 +16,8 @@ export class TagsListComponent extends React.Component { forceListTags: PropTypes.func, tagsList: PropTypes.shape({ loading: PropTypes.bool, + error: PropTypes.bool, + filteredTags: PropTypes.arrayOf(PropTypes.string), }), match: PropTypes.object, }; @@ -23,7 +25,7 @@ export class TagsListComponent extends React.Component { componentDidMount() { const { forceListTags } = this.props; - forceListTags(true); + forceListTags(); } renderContent() { @@ -47,7 +49,7 @@ export class TagsListComponent extends React.Component { return No tags found; } - const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUP_SIZE), tagsList.filteredTags); + const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags); return ( @@ -71,13 +73,9 @@ export class TagsListComponent extends React.Component { return (
- {!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('', () => {