From 0b089e24de961fcb32ca3b9b179b615272370c8a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 27 Aug 2018 16:53:09 +0200 Subject: [PATCH 01/24] Moved propTypes and defaultProps as static properties in class components --- src/common/Home.js | 12 +++--- src/common/MainHeader.js | 10 ++--- src/common/MenuLayout.js | 16 ++++---- src/common/ScrollToTop.js | 25 ++++++------- src/servers/DeleteServerButton.js | 12 +++--- src/servers/ServersDropdown.js | 29 +++++++-------- src/servers/helpers/ImportServersBtn.js | 25 ++++++------- src/short-urls/ShortUrlVisits.js | 37 +++++++++---------- src/short-urls/ShortUrlsList.js | 24 ++++++------ .../helpers/CreateShortUrlResult.js | 14 +++---- src/short-urls/helpers/EditTagsModal.js | 24 ++++++------ src/short-urls/helpers/ShortUrlsRow.js | 16 ++++---- src/short-urls/helpers/ShortUrlsRowMenu.js | 16 ++++---- src/tags/TagCard.js | 21 +++++------ src/tags/TagsList.js | 19 +++++----- src/tags/helpers/DeleteTagConfirmModal.js | 20 +++++----- src/tags/helpers/EditTagModal.js | 35 ++++++++---------- src/utils/SearchField.js | 22 +++++------ 18 files changed, 168 insertions(+), 209 deletions(-) diff --git a/src/common/Home.js b/src/common/Home.js index 3aa66f89..a3293d49 100644 --- a/src/common/Home.js +++ b/src/common/Home.js @@ -9,12 +9,12 @@ import PropTypes from 'prop-types'; import { resetSelectedServer } from '../servers/reducers/selectedServer'; import './Home.scss'; -const propTypes = { - resetSelectedServer: PropTypes.func, - servers: PropTypes.object, -}; - export class HomeComponent extends React.Component { + static propTypes = { + resetSelectedServer: PropTypes.func, + servers: PropTypes.object, + }; + componentDidMount() { this.props.resetSelectedServer(); } @@ -51,8 +51,6 @@ export class HomeComponent extends React.Component { } } -HomeComponent.propTypes = propTypes; - const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent); export default Home; diff --git a/src/common/MainHeader.js b/src/common/MainHeader.js index 87b6d2ef..76836714 100644 --- a/src/common/MainHeader.js +++ b/src/common/MainHeader.js @@ -10,11 +10,11 @@ import ServersDropdown from '../servers/ServersDropdown'; import './MainHeader.scss'; import shlinkLogo from './shlink-logo-white.png'; -const propTypes = { - location: PropTypes.object, -}; - export class MainHeaderComponent extends React.Component { + static propTypes = { + location: PropTypes.object, + }; + state = { isOpen: false }; handleToggle = () => { this.setState(({ isOpen }) => ({ @@ -64,8 +64,6 @@ export class MainHeaderComponent extends React.Component { } } -MainHeaderComponent.propTypes = propTypes; - const MainHeader = withRouter(MainHeaderComponent); export default MainHeader; diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index 3f3c7b41..a42b1d49 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -17,14 +17,14 @@ import TagsList from '../tags/TagsList'; import { serverType } from '../servers/prop-types'; import AsideMenu from './AsideMenu'; -const propTypes = { - match: PropTypes.object, - selectServer: PropTypes.func, - location: PropTypes.object, - selectedServer: serverType, -}; - export class MenuLayoutComponent extends React.Component { + static propTypes = { + match: PropTypes.object, + selectServer: PropTypes.func, + location: PropTypes.object, + selectedServer: serverType, + }; + state = { showSideBar: false }; // FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered @@ -105,8 +105,6 @@ export class MenuLayoutComponent extends React.Component { } } -MenuLayoutComponent.propTypes = propTypes; - const MenuLayout = compose( connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }), withRouter diff --git a/src/common/ScrollToTop.js b/src/common/ScrollToTop.js index f14b4c79..789f1a33 100644 --- a/src/common/ScrollToTop.js +++ b/src/common/ScrollToTop.js @@ -2,18 +2,18 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; -const propTypes = { - location: PropTypes.object, - window: PropTypes.shape({ - scrollTo: PropTypes.func, - }), - children: PropTypes.node, -}; -const defaultProps = { - window, -}; - export class ScrollToTopComponent extends React.Component { + static propTypes = { + location: PropTypes.object, + window: PropTypes.shape({ + scrollTo: PropTypes.func, + }), + children: PropTypes.node, + }; + static defaultProps = { + window, + }; + componentDidUpdate(prevProps) { const { location, window } = this.props; @@ -27,9 +27,6 @@ export class ScrollToTopComponent extends React.Component { } } -ScrollToTopComponent.defaultProps = defaultProps; -ScrollToTopComponent.propTypes = propTypes; - const ScrollToTop = withRouter(ScrollToTopComponent); export default ScrollToTop; diff --git a/src/servers/DeleteServerButton.js b/src/servers/DeleteServerButton.js index 5dff7244..7d161fc1 100644 --- a/src/servers/DeleteServerButton.js +++ b/src/servers/DeleteServerButton.js @@ -5,12 +5,12 @@ import PropTypes from 'prop-types'; import DeleteServerModal from './DeleteServerModal'; import { serverType } from './prop-types'; -const propTypes = { - server: serverType, - className: PropTypes.string, -}; - export default class DeleteServerButton extends React.Component { + static propTypes = { + server: serverType, + className: PropTypes.string, + }; + state = { isModalOpen: false }; render() { @@ -37,5 +37,3 @@ export default class DeleteServerButton extends React.Component { ); } } - -DeleteServerButton.propTypes = propTypes; diff --git a/src/servers/ServersDropdown.js b/src/servers/ServersDropdown.js index a990f302..84c96fac 100644 --- a/src/servers/ServersDropdown.js +++ b/src/servers/ServersDropdown.js @@ -9,20 +9,20 @@ import serversExporter from '../servers/services/ServersExporter'; import { listServers } from './reducers/server'; import { serverType } from './prop-types'; -const defaultProps = { - serversExporter, -}; -const propTypes = { - servers: PropTypes.object, - serversExporter: PropTypes.shape({ - exportServers: PropTypes.func, - }), - selectedServer: serverType, - selectServer: PropTypes.func, - listServers: PropTypes.func, -}; - export class ServersDropdownComponent extends React.Component { + static defaultProps = { + serversExporter, + }; + static propTypes = { + servers: PropTypes.object, + serversExporter: PropTypes.shape({ + exportServers: PropTypes.func, + }), + selectedServer: serverType, + selectServer: PropTypes.func, + listServers: PropTypes.func, + }; + renderServers = () => { const { servers, selectedServer, selectServer, serversExporter } = this.props; @@ -70,9 +70,6 @@ export class ServersDropdownComponent extends React.Component { } } -ServersDropdownComponent.defaultProps = defaultProps; -ServersDropdownComponent.propTypes = propTypes; - const ServersDropdown = connect( pick([ 'servers', 'selectedServer' ]), { listServers, selectServer } diff --git a/src/servers/helpers/ImportServersBtn.js b/src/servers/helpers/ImportServersBtn.js index 431d3d13..b5653674 100644 --- a/src/servers/helpers/ImportServersBtn.js +++ b/src/servers/helpers/ImportServersBtn.js @@ -7,18 +7,18 @@ import PropTypes from 'prop-types'; import { createServers } from '../reducers/server'; import serversImporter, { serversImporterType } from '../services/ServersImporter'; -const defaultProps = { - serversImporter, - onImport: () => ({}), -}; -const propTypes = { - onImport: PropTypes.func, - serversImporter: serversImporterType, - createServers: PropTypes.func, - fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]), -}; - export class ImportServersBtnComponent extends React.Component { + static defaultProps = { + serversImporter, + onImport: () => ({}), + }; + static propTypes = { + onImport: PropTypes.func, + serversImporter: serversImporterType, + createServers: PropTypes.func, + fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]), + }; + constructor(props) { super(props); this.fileRef = props.fileRef || React.createRef(); @@ -58,9 +58,6 @@ export class ImportServersBtnComponent extends React.Component { } } -ImportServersBtnComponent.defaultProps = defaultProps; -ImportServersBtnComponent.propTypes = propTypes; - const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent); export default ImportServersBtn; diff --git a/src/short-urls/ShortUrlVisits.js b/src/short-urls/ShortUrlVisits.js index 534026b0..99e67687 100644 --- a/src/short-urls/ShortUrlVisits.js +++ b/src/short-urls/ShortUrlVisits.js @@ -20,24 +20,24 @@ import { serverType } from '../servers/prop-types'; import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; import './ShortUrlVisits.scss'; -const propTypes = { - processOsStats: PropTypes.func, - processBrowserStats: PropTypes.func, - processCountriesStats: PropTypes.func, - processReferrersStats: PropTypes.func, - match: PropTypes.object, - getShortUrlVisits: PropTypes.func, - selectedServer: serverType, - shortUrlVisits: shortUrlVisitsType, -}; -const defaultProps = { - processOsStats, - processBrowserStats, - processCountriesStats, - processReferrersStats, -}; - export class ShortUrlsVisitsComponent extends React.Component { + static propTypes = { + processOsStats: PropTypes.func, + processBrowserStats: PropTypes.func, + processCountriesStats: PropTypes.func, + processReferrersStats: PropTypes.func, + match: PropTypes.object, + getShortUrlVisits: PropTypes.func, + selectedServer: serverType, + shortUrlVisits: shortUrlVisitsType, + }; + static defaultProps = { + processOsStats, + processBrowserStats, + processCountriesStats, + processReferrersStats, + }; + state = { startDate: undefined, endDate: undefined }; loadVisits = () => { const { match: { params }, getShortUrlVisits } = this.props; @@ -210,9 +210,6 @@ export class ShortUrlsVisitsComponent extends React.Component { } } -ShortUrlsVisitsComponent.propTypes = propTypes; -ShortUrlsVisitsComponent.defaultProps = defaultProps; - const ShortUrlsVisits = connect( pick([ 'selectedServer', 'shortUrlVisits' ]), { getShortUrlVisits } diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index e68160e6..43335d7f 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -20,18 +20,18 @@ const SORTABLE_FIELDS = { visits: 'Visits', }; -const propTypes = { - listShortUrls: PropTypes.func, - shortUrlsListParams: shortUrlsListParamsType, - match: PropTypes.object, - location: PropTypes.object, - loading: PropTypes.bool, - error: PropTypes.bool, - shortUrlsList: PropTypes.arrayOf(shortUrlType), - selectedServer: serverType, -}; - export class ShortUrlsListComponent extends React.Component { + static propTypes = { + listShortUrls: PropTypes.func, + shortUrlsListParams: shortUrlsListParamsType, + match: PropTypes.object, + location: PropTypes.object, + loading: PropTypes.bool, + error: PropTypes.bool, + shortUrlsList: PropTypes.arrayOf(shortUrlType), + selectedServer: serverType, + }; + refreshList = (extraParams) => { const { listShortUrls, shortUrlsListParams } = this.props; @@ -186,8 +186,6 @@ export class ShortUrlsListComponent extends React.Component { } } -ShortUrlsListComponent.propTypes = propTypes; - const ShortUrlsList = connect( pick([ 'selectedServer', 'shortUrlsListParams' ]), { listShortUrls } diff --git a/src/short-urls/helpers/CreateShortUrlResult.js b/src/short-urls/helpers/CreateShortUrlResult.js index aac34acd..5dd2aae4 100644 --- a/src/short-urls/helpers/CreateShortUrlResult.js +++ b/src/short-urls/helpers/CreateShortUrlResult.js @@ -9,13 +9,13 @@ import { createShortUrlResultType } from '../reducers/shortUrlCreationResult'; import { stateFlagTimeout } from '../../utils/utils'; import './CreateShortUrlResult.scss'; -const propTypes = { - resetCreateShortUrl: PropTypes.func, - error: PropTypes.bool, - result: createShortUrlResultType, -}; - export default class CreateShortUrlResult extends React.Component { + static propTypes = { + resetCreateShortUrl: PropTypes.func, + error: PropTypes.bool, + result: createShortUrlResultType, + }; + state = { showCopyTooltip: false }; componentDidMount() { @@ -62,5 +62,3 @@ export default class CreateShortUrlResult extends React.Component { ); } } - -CreateShortUrlResult.propTypes = propTypes; diff --git a/src/short-urls/helpers/EditTagsModal.js b/src/short-urls/helpers/EditTagsModal.js index 0a8e95d0..38a95949 100644 --- a/src/short-urls/helpers/EditTagsModal.js +++ b/src/short-urls/helpers/EditTagsModal.js @@ -13,18 +13,18 @@ import { import ExternalLink from '../../utils/ExternalLink'; import { shortUrlType } from '../reducers/shortUrlsList'; -const propTypes = { - isOpen: PropTypes.bool.isRequired, - toggle: PropTypes.func.isRequired, - url: PropTypes.string.isRequired, - shortUrl: shortUrlType.isRequired, - shortUrlTags: shortUrlTagsType, - editShortUrlTags: PropTypes.func, - shortUrlTagsEdited: PropTypes.func, - resetShortUrlsTags: PropTypes.func, -}; - export class EditTagsModalComponent extends React.Component { + static propTypes = { + isOpen: PropTypes.bool.isRequired, + toggle: PropTypes.func.isRequired, + url: PropTypes.string.isRequired, + shortUrl: shortUrlType.isRequired, + shortUrlTags: shortUrlTagsType, + editShortUrlTags: PropTypes.func, + shortUrlTagsEdited: PropTypes.func, + resetShortUrlsTags: PropTypes.func, + }; + saveTags = () => { const { editShortUrlTags, shortUrl, toggle } = this.props; @@ -90,8 +90,6 @@ export class EditTagsModalComponent extends React.Component { } } -EditTagsModalComponent.propTypes = propTypes; - const EditTagsModal = connect( pick([ 'shortUrlTags' ]), { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } diff --git a/src/short-urls/helpers/ShortUrlsRow.js b/src/short-urls/helpers/ShortUrlsRow.js index f4e2bc05..0fbd9e82 100644 --- a/src/short-urls/helpers/ShortUrlsRow.js +++ b/src/short-urls/helpers/ShortUrlsRow.js @@ -11,14 +11,14 @@ import { stateFlagTimeout } from '../../utils/utils'; import { ShortUrlsRowMenu } from './ShortUrlsRowMenu'; import './ShortUrlsRow.scss'; -const propTypes = { - refreshList: PropTypes.func, - shortUrlsListParams: shortUrlsListParamsType, - selectedServer: serverType, - shortUrl: shortUrlType, -}; - export class ShortUrlsRow extends React.Component { + static propTypes = { + refreshList: PropTypes.func, + shortUrlsListParams: shortUrlsListParamsType, + selectedServer: serverType, + shortUrl: shortUrlType, + }; + state = { copiedToClipboard: false }; renderTags(tags) { @@ -73,5 +73,3 @@ export class ShortUrlsRow extends React.Component { ); } } - -ShortUrlsRow.propTypes = propTypes; diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.js b/src/short-urls/helpers/ShortUrlsRowMenu.js index 436817c3..c835a17b 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.js +++ b/src/short-urls/helpers/ShortUrlsRowMenu.js @@ -17,14 +17,14 @@ import QrCodeModal from './QrCodeModal'; import './ShortUrlsRowMenu.scss'; import EditTagsModal from './EditTagsModal'; -const propTypes = { - completeShortUrl: PropTypes.string, - onCopyToClipboard: PropTypes.func, - selectedServer: serverType, - shortUrl: shortUrlType, -}; - export class ShortUrlsRowMenu extends React.Component { + static propTypes = { + completeShortUrl: PropTypes.string, + onCopyToClipboard: PropTypes.func, + selectedServer: serverType, + shortUrl: shortUrlType, + }; + state = { isOpen: false, isQrModalOpen: false, @@ -91,5 +91,3 @@ export class ShortUrlsRowMenu extends React.Component { ); } } - -ShortUrlsRowMenu.propTypes = propTypes; diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js index 2ccfd21c..b4e65959 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -10,16 +10,16 @@ import './TagCard.scss'; import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal'; import EditTagModal from './helpers/EditTagModal'; -const propTypes = { - tag: PropTypes.string, - currentServerId: PropTypes.string, - colorGenerator: colorGeneratorType, -}; -const defaultProps = { - colorGenerator, -}; - export default class TagCard extends React.Component { + static propTypes = { + tag: PropTypes.string, + currentServerId: PropTypes.string, + colorGenerator: colorGeneratorType, + }; + static defaultProps = { + colorGenerator, + }; + state = { isDeleteModalOpen: false, isEditModalOpen: false }; render() { @@ -69,6 +69,3 @@ export default class TagCard extends React.Component { ); } } - -TagCard.propTypes = propTypes; -TagCard.defaultProps = defaultProps; diff --git a/src/tags/TagsList.js b/src/tags/TagsList.js index c87ddfbd..b1f0a213 100644 --- a/src/tags/TagsList.js +++ b/src/tags/TagsList.js @@ -9,16 +9,17 @@ import TagCard from './TagCard'; const { ceil } = Math; const TAGS_GROUP_SIZE = 4; -const propTypes = { - filterTags: PropTypes.func, - listTags: PropTypes.func, - tagsList: PropTypes.shape({ - loading: PropTypes.bool, - }), - match: PropTypes.object, -}; export class TagsListComponent extends React.Component { + static propTypes = { + filterTags: PropTypes.func, + listTags: PropTypes.func, + tagsList: PropTypes.shape({ + loading: PropTypes.bool, + }), + match: PropTypes.object, + }; + componentDidMount() { const { listTags } = this.props; @@ -85,8 +86,6 @@ export class TagsListComponent extends React.Component { } } -TagsListComponent.propTypes = propTypes; - const TagsList = connect(pick([ 'tagsList' ]), { listTags, filterTags })(TagsListComponent); export default TagsList; diff --git a/src/tags/helpers/DeleteTagConfirmModal.js b/src/tags/helpers/DeleteTagConfirmModal.js index 0cb2b1d8..e201c799 100644 --- a/src/tags/helpers/DeleteTagConfirmModal.js +++ b/src/tags/helpers/DeleteTagConfirmModal.js @@ -5,16 +5,16 @@ import PropTypes from 'prop-types'; import { pick } from 'ramda'; import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete'; -const propTypes = { - tag: PropTypes.string.isRequired, - toggle: PropTypes.func.isRequired, - isOpen: PropTypes.bool.isRequired, - deleteTag: PropTypes.func, - tagDelete: tagDeleteType, - tagDeleted: PropTypes.func, -}; - export class DeleteTagConfirmModalComponent extends React.Component { + static propTypes = { + tag: PropTypes.string.isRequired, + toggle: PropTypes.func.isRequired, + isOpen: PropTypes.bool.isRequired, + deleteTag: PropTypes.func, + tagDelete: tagDeleteType, + tagDeleted: PropTypes.func, + }; + doDelete = () => { const { tag, toggle, deleteTag } = this.props; @@ -68,8 +68,6 @@ export class DeleteTagConfirmModalComponent extends React.Component { } } -DeleteTagConfirmModalComponent.propTypes = propTypes; - const DeleteTagConfirmModal = connect( pick([ 'tagDelete' ]), { deleteTag, tagDeleted } diff --git a/src/tags/helpers/EditTagModal.js b/src/tags/helpers/EditTagModal.js index defbef5d..91e58bd9 100644 --- a/src/tags/helpers/EditTagModal.js +++ b/src/tags/helpers/EditTagModal.js @@ -10,23 +10,23 @@ import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator'; import { editTag, tagEdited } from '../reducers/tagEdit'; import './EditTagModal.scss'; -const propTypes = { - tag: PropTypes.string, - editTag: PropTypes.func, - toggle: PropTypes.func, - tagEdited: PropTypes.func, - colorGenerator: colorGeneratorType, - isOpen: PropTypes.bool, - tagEdit: PropTypes.shape({ - error: PropTypes.bool, - editing: PropTypes.bool, - }), -}; -const defaultProps = { - colorGenerator, -}; - export class EditTagModalComponent extends React.Component { + static propTypes = { + tag: PropTypes.string, + editTag: PropTypes.func, + toggle: PropTypes.func, + tagEdited: PropTypes.func, + colorGenerator: colorGeneratorType, + isOpen: PropTypes.bool, + tagEdit: PropTypes.shape({ + error: PropTypes.bool, + editing: PropTypes.bool, + }), + }; + static defaultProps = { + colorGenerator, + }; + saveTag = (e) => { e.preventDefault(); const { tag: oldName, editTag, toggle } = this.props; @@ -133,9 +133,6 @@ export class EditTagModalComponent extends React.Component { } } -EditTagModalComponent.propTypes = propTypes; -EditTagModalComponent.defaultProps = defaultProps; - const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent); export default EditTagModal; diff --git a/src/utils/SearchField.js b/src/utils/SearchField.js index 5ed91632..f741ebb5 100644 --- a/src/utils/SearchField.js +++ b/src/utils/SearchField.js @@ -6,17 +6,18 @@ import classnames from 'classnames'; import './SearchField.scss'; const DEFAULT_SEARCH_INTERVAL = 500; -const propTypes = { - onChange: PropTypes.func.isRequired, - className: PropTypes.string, - placeholder: PropTypes.string, -}; -const defaultProps = { - className: '', - placeholder: 'Search...', -}; export default class SearchField extends React.Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + className: PropTypes.string, + placeholder: PropTypes.string, + }; + static defaultProps = { + className: '', + placeholder: 'Search...', + }; + state = { showClearBtn: false, searchTerm: '' }; timer = null; @@ -64,6 +65,3 @@ export default class SearchField extends React.Component { ); } } - -SearchField.propTypes = propTypes; -SearchField.defaultProps = defaultProps; From 1519f89318bd47ea28e9ca0e7b8b168899204f22 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 28 Aug 2018 22:47:46 +0200 Subject: [PATCH 02/24] Created different functions which load tags always or only once --- src/tags/TagsList.js | 10 +++++----- src/tags/reducers/tagsList.js | 12 ++++++++++-- src/utils/TagsSelector.js | 16 ++++++---------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/tags/TagsList.js b/src/tags/TagsList.js index b1f0a213..b23accb3 100644 --- a/src/tags/TagsList.js +++ b/src/tags/TagsList.js @@ -4,7 +4,7 @@ import { pick, splitEvery } from 'ramda'; import PropTypes from 'prop-types'; import MuttedMessage from '../utils/MuttedMessage'; import SearchField from '../utils/SearchField'; -import { filterTags, listTags } from './reducers/tagsList'; +import { filterTags, forceListTags } from './reducers/tagsList'; import TagCard from './TagCard'; const { ceil } = Math; @@ -13,7 +13,7 @@ const TAGS_GROUP_SIZE = 4; export class TagsListComponent extends React.Component { static propTypes = { filterTags: PropTypes.func, - listTags: PropTypes.func, + forceListTags: PropTypes.func, tagsList: PropTypes.shape({ loading: PropTypes.bool, }), @@ -21,9 +21,9 @@ export class TagsListComponent extends React.Component { }; componentDidMount() { - const { listTags } = this.props; + const { forceListTags } = this.props; - listTags(); + forceListTags(true); } renderContent() { @@ -86,6 +86,6 @@ export class TagsListComponent extends React.Component { } } -const TagsList = connect(pick([ 'tagsList' ]), { listTags, filterTags })(TagsListComponent); +const TagsList = connect(pick([ 'tagsList' ]), { forceListTags, filterTags })(TagsListComponent); export default TagsList; diff --git a/src/tags/reducers/tagsList.js b/src/tags/reducers/tagsList.js index c9309914..5cd229a0 100644 --- a/src/tags/reducers/tagsList.js +++ b/src/tags/reducers/tagsList.js @@ -1,4 +1,4 @@ -import { reject } from 'ramda'; +import { isEmpty, reject } from 'ramda'; import shlinkApiClient from '../../api/ShlinkApiClient'; import { TAG_DELETED } from './tagDelete'; import { TAG_EDITED } from './tagEdit'; @@ -68,7 +68,13 @@ export default function reducer(state = defaultState, action) { } } -export const _listTags = (shlinkApiClient) => async (dispatch) => { +export const _listTags = (shlinkApiClient, force = false) => async (dispatch, getState) => { + const { tagsList } = getState(); + + if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) { + return; + } + dispatch({ type: LIST_TAGS_START }); try { @@ -82,6 +88,8 @@ export const _listTags = (shlinkApiClient) => async (dispatch) => { export const listTags = () => _listTags(shlinkApiClient); +export const forceListTags = () => _listTags(shlinkApiClient, true); + export const filterTags = (searchTerm) => ({ type: FILTER_TAGS, searchTerm, diff --git a/src/utils/TagsSelector.js b/src/utils/TagsSelector.js index 35b82a37..0630912d 100644 --- a/src/utils/TagsSelector.js +++ b/src/utils/TagsSelector.js @@ -15,16 +15,12 @@ const propTypes = { }; export default function TagsSelector({ tags, onChange, placeholder, colorGenerator }) { - const renderTag = (props) => { - const { tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other } = props; - - return ( - - {getTagDisplayValue(tag)} - {!disabled && onRemove(key)} />} - - ); - }; + const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => ( + + {getTagDisplayValue(tag)} + {!disabled && onRemove(key)} />} + + ); return ( Date: Fri, 31 Aug 2018 18:00:33 +0200 Subject: [PATCH 03/24] Added first autocomplete implementation on tags selector --- .eslintrc | 3 +- README.md | 1 + package.json | 1 + src/tags/TagCard.js | 13 +--- src/tags/TagCard.scss | 11 --- src/tags/reducers/tagsList.js | 4 +- src/utils/TagBullet.js | 24 +++++++ src/utils/TagBullet.scss | 10 +++ src/utils/TagsSelector.js | 122 +++++++++++++++++++++++++--------- src/utils/TagsSelector.scss | 16 +++++ yarn.lock | 34 ++++++++++ 11 files changed, 184 insertions(+), 55 deletions(-) create mode 100644 src/utils/TagBullet.js create mode 100644 src/utils/TagBullet.scss create mode 100644 src/utils/TagsSelector.scss diff --git a/.eslintrc b/.eslintrc index 8839bdef..7207fac3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -35,6 +35,7 @@ "react/jsx-first-prop-new-line": ["error", "multiline-multiprop"], "react/jsx-closing-bracket-location": ["error", "tag-aligned"], "react/no-array-index-key": "off", - "react/no-did-update-set-state": "off" + "react/no-did-update-set-state": "off", + "react/display-name": "off" } } diff --git a/README.md b/README.md index 88727204..3b2b1940 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master) [![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest) [![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE) +[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alejandrocelaya%40gmail.com¤cy_code=EUR) A ReactJS-based progressive web application for [Shlink](https://shlink.io). diff --git a/package.json b/package.json index 9280a206..55c84580 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "qs": "^6.5.2", "ramda": "^0.25.0", "react": "^16.3.2", + "react-autosuggest": "^9.4.0", "react-chartjs-2": "^2.7.4", "react-color": "^2.14.1", "react-copy-to-clipboard": "^5.0.1", diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js index b4e65959..3222a37d 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -5,7 +5,7 @@ import editIcon from '@fortawesome/fontawesome-free-solid/faPencilAlt'; import PropTypes from 'prop-types'; import React from 'react'; import { Link } from 'react-router-dom'; -import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator'; +import TagBullet from '../utils/TagBullet'; import './TagCard.scss'; import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal'; import EditTagModal from './helpers/EditTagModal'; @@ -14,16 +14,12 @@ export default class TagCard extends React.Component { static propTypes = { tag: PropTypes.string, currentServerId: PropTypes.string, - colorGenerator: colorGeneratorType, - }; - static defaultProps = { - colorGenerator, }; state = { isDeleteModalOpen: false, isEditModalOpen: false }; render() { - const { tag, colorGenerator, currentServerId } = this.props; + const { tag, currentServerId } = this.props; const toggleDelete = () => this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen })); const toggleEdit = () => @@ -45,10 +41,7 @@ export default class TagCard extends React.Component {
-
+ {tag} diff --git a/src/tags/TagCard.scss b/src/tags/TagCard.scss index 6e2b56d7..c30300c3 100644 --- a/src/tags/TagCard.scss +++ b/src/tags/TagCard.scss @@ -16,17 +16,6 @@ padding-right: 5px; } -.tag-card__tag-bullet { - $width: 20px; - - border-radius: 50%; - width: $width; - height: $width; - display: inline-block; - vertical-align: -4px; - margin-right: 7px; -} - .tag-card__btn { float: right; } diff --git a/src/tags/reducers/tagsList.js b/src/tags/reducers/tagsList.js index 5cd229a0..cb415902 100644 --- a/src/tags/reducers/tagsList.js +++ b/src/tags/reducers/tagsList.js @@ -59,9 +59,7 @@ export default function reducer(state = defaultState, action) { case FILTER_TAGS: return { ...state, - filteredTags: state.tags.filter( - (tag) => tag.toLowerCase().match(action.searchTerm), - ), + filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(action.searchTerm)), }; default: return state; diff --git a/src/utils/TagBullet.js b/src/utils/TagBullet.js new file mode 100644 index 00000000..ccdc4801 --- /dev/null +++ b/src/utils/TagBullet.js @@ -0,0 +1,24 @@ +import React from 'react'; +import * as PropTypes from 'prop-types'; +import colorGenerator, { colorGeneratorType } from './ColorGenerator'; +import './TagBullet.scss'; + +const propTypes = { + tag: PropTypes.string.isRequired, + colorGenerator: colorGeneratorType, +}; +const defaultProps = { + colorGenerator, +}; + +export default function TagBullet({ tag, colorGenerator }) { + return ( +
+ ); +} + +TagBullet.propTypes = propTypes; +TagBullet.defaultProps = defaultProps; diff --git a/src/utils/TagBullet.scss b/src/utils/TagBullet.scss new file mode 100644 index 00000000..ad795a9d --- /dev/null +++ b/src/utils/TagBullet.scss @@ -0,0 +1,10 @@ +.tag-bullet { + $width: 20px; + + border-radius: 50%; + width: $width; + height: $width; + display: inline-block; + vertical-align: -4px; + margin-right: 7px; +} diff --git a/src/utils/TagsSelector.js b/src/utils/TagsSelector.js index 0630912d..5b8eeb00 100644 --- a/src/utils/TagsSelector.js +++ b/src/utils/TagsSelector.js @@ -1,40 +1,102 @@ import React from 'react'; +import { connect } from 'react-redux'; import TagsInput from 'react-tagsinput'; import PropTypes from 'prop-types'; +import Autosuggest from 'react-autosuggest'; +import { pick, identity } from 'ramda'; +import { listTags } from '../tags/reducers/tagsList'; import colorGenerator, { colorGeneratorType } from './ColorGenerator'; +import './TagsSelector.scss'; +import TagBullet from './TagBullet'; -const defaultProps = { - colorGenerator, - placeholder: 'Add tags to the URL', -}; -const propTypes = { - tags: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired, - placeholder: PropTypes.string, - colorGenerator: colorGeneratorType, -}; +export class TagsSelectorComponent extends React.Component { + static propTypes = { + tags: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + colorGenerator: colorGeneratorType, + tagsList: PropTypes.shape({ + tags: PropTypes.arrayOf(PropTypes.string), + }), + }; + static defaultProps = { + colorGenerator, + placeholder: 'Add tags to the URL', + }; -export default function TagsSelector({ tags, onChange, placeholder, colorGenerator }) { - const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => ( - - {getTagDisplayValue(tag)} - {!disabled && onRemove(key)} />} - - ); + componentDidMount() { + const { listTags } = this.props; - return ( - - ); + render() { + const { tags, onChange, placeholder, colorGenerator, tagsList } = this.props; + const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => ( + + {getTagDisplayValue(tag)} + {!disabled && onRemove(key)} />} + + ); + const renderAutocompleteInput = (data) => { + const { addTag, ...rest } = data; + + const handleOnChange = (e, { method }) => { + if (method === 'enter') { + e.preventDefault(); + } else { + rest.onChange(e); + } + }; + + // eslint-disable-next-line no-extra-parens + const inputValue = (rest.value && rest.value.trim().toLowerCase()) || ''; + const inputLength = inputValue.length; + const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue); + + return ( + value && value.trim().length > 0} + getSuggestionValue={(suggestion) => { + console.log(suggestion); + + return suggestion; + }} + renderSuggestion={(suggestion) => ( + + + {suggestion} + + )} + onSuggestionSelected={(e, { suggestion }) => { + addTag(suggestion); + }} + onSuggestionsClearRequested={identity} + onSuggestionsFetchRequested={identity} + /> + ); + }; + + return ( + + ); + } } -TagsSelector.defaultProps = defaultProps; -TagsSelector.propTypes = propTypes; +const TagsSelector = connect(pick([ 'tagsList' ]), { listTags })(TagsSelectorComponent); + +export default TagsSelector; diff --git a/src/utils/TagsSelector.scss b/src/utils/TagsSelector.scss new file mode 100644 index 00000000..24a68c8e --- /dev/null +++ b/src/utils/TagsSelector.scss @@ -0,0 +1,16 @@ +@import './base'; + +.react-autosuggest__suggestions-list { + list-style-type: none; + padding: 0; + margin-bottom: 6px; +} + +.react-autosuggest__suggestion { + margin-left: -6px; + padding: 5px 8px; +} + +.react-autosuggest__suggestion--highlighted { + background-color: $lightGrey; +} diff --git a/yarn.lock b/yarn.lock index f3deac78..a2b0dc21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5691,6 +5691,10 @@ object-assign@4.1.1, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^ version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" +object-assign@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" + object-copy@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" @@ -6717,6 +6721,22 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-autosuggest@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.0.tgz#3146bc9afa4f171bed067c542421edec5ca94294" + dependencies: + prop-types "^15.5.10" + react-autowhatever "^10.1.2" + shallow-equal "^1.0.0" + +react-autowhatever@^10.1.2: + version "10.1.2" + resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.1.2.tgz#200ffc41373b2189e3f6140ac7bdb82363a79fd3" + dependencies: + prop-types "^15.5.8" + react-themeable "^1.1.0" + section-iterator "^2.0.0" + react-chartjs-2@^2.7.4: version "2.7.4" resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-2.7.4.tgz#e41ea4e81491dc78347111126a48e96ee57db1a6" @@ -6879,6 +6899,12 @@ react-test-renderer@^16.0.0-0: prop-types "^15.6.0" react-is "^16.4.2" +react-themeable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" + dependencies: + object-assign "^3.0.0" + react-transition-group@^2.3.1: version "2.4.0" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.4.0.tgz#1d9391fabfd82e016f26fabd1eec329dbd922b5a" @@ -7439,6 +7465,10 @@ scss-tokenizer@^0.2.3: js-base64 "^2.1.8" source-map "^0.4.2" +section-iterator@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -7586,6 +7616,10 @@ shallow-clone@^1.0.0: kind-of "^5.0.0" mixin-object "^2.0.1" +shallow-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" From fd57d70a0bfc46cdf4d5dfea85ec96b00dc86961 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 31 Aug 2018 21:19:58 +0200 Subject: [PATCH 04/24] Fixed tags input autofocus --- src/common/react-tagsinput.scss | 7 ++----- src/utils/TagsSelector.js | 21 ++++++--------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/common/react-tagsinput.scss b/src/common/react-tagsinput.scss index cd029d09..2e230758 100644 --- a/src/common/react-tagsinput.scss +++ b/src/common/react-tagsinput.scss @@ -5,15 +5,12 @@ overflow: hidden; min-height: 2.6rem; padding: 6px 0 0 6px; + transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; } .react-tagsinput--focused { border-color: #80bdff; - -webkit-box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25); box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25); - -webkit-transition: border-color .15s ease-in-out, -webkit-box-shadow .15s ease-in-out; - -o-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; - transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out, -webkit-box-shadow .15s ease-in-out; } .react-tagsinput-tag { @@ -44,6 +41,6 @@ border: 0; outline: none; padding: 3px 5px; - width: 155px; + width: 100%; margin-bottom: 6px; } diff --git a/src/utils/TagsSelector.js b/src/utils/TagsSelector.js index 5b8eeb00..141947f9 100644 --- a/src/utils/TagsSelector.js +++ b/src/utils/TagsSelector.js @@ -39,33 +39,24 @@ export class TagsSelectorComponent extends React.Component { ); const renderAutocompleteInput = (data) => { - const { addTag, ...rest } = data; - + const { addTag, ...otherProps } = data; const handleOnChange = (e, { method }) => { - if (method === 'enter') { - e.preventDefault(); - } else { - rest.onChange(e); - } + method === 'enter' ? e.preventDefault() : otherProps.onChange(e); }; // eslint-disable-next-line no-extra-parens - const inputValue = (rest.value && rest.value.trim().toLowerCase()) || ''; + const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || ''; const inputLength = inputValue.length; const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue); return ( value && value.trim().length > 0} - getSuggestionValue={(suggestion) => { - console.log(suggestion); - - return suggestion; - }} + getSuggestionValue={(suggestion) => suggestion} renderSuggestion={(suggestion) => ( From b45481035763531fe22a7d3d7571b07706c8a6b1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Sep 2018 10:30:01 +0200 Subject: [PATCH 05/24] Moved tag helper components from utils to tags/helpers folder --- src/short-urls/CreateShortUrl.js | 2 +- src/short-urls/SearchBar.js | 2 +- src/short-urls/helpers/EditTagsModal.js | 2 +- src/short-urls/helpers/ShortUrlsRow.js | 2 +- src/tags/TagCard.js | 2 +- src/{utils => tags/helpers}/Tag.js | 2 +- src/{utils => tags/helpers}/Tag.scss | 0 src/{utils => tags/helpers}/TagBullet.js | 2 +- src/{utils => tags/helpers}/TagBullet.scss | 0 src/{utils => tags/helpers}/TagsSelector.js | 4 ++-- src/{utils => tags/helpers}/TagsSelector.scss | 2 +- 11 files changed, 10 insertions(+), 10 deletions(-) rename src/{utils => tags/helpers}/Tag.js (91%) rename src/{utils => tags/helpers}/Tag.scss (100%) rename src/{utils => tags/helpers}/TagBullet.js (86%) rename src/{utils => tags/helpers}/TagBullet.scss (100%) rename src/{utils => tags/helpers}/TagsSelector.js (95%) rename src/{utils => tags/helpers}/TagsSelector.scss (90%) diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index 3db73243..5d1bfde6 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -6,7 +6,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Collapse } from 'reactstrap'; import DateInput from '../common/DateInput'; -import TagsSelector from '../utils/TagsSelector'; +import TagsSelector from '../tags/helpers/TagsSelector'; import CreateShortUrlResult from './helpers/CreateShortUrlResult'; import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult'; diff --git a/src/short-urls/SearchBar.js b/src/short-urls/SearchBar.js index c17dba62..6cd5cf3d 100644 --- a/src/short-urls/SearchBar.js +++ b/src/short-urls/SearchBar.js @@ -4,7 +4,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { isEmpty, pick } from 'ramda'; import PropTypes from 'prop-types'; -import Tag from '../utils/Tag'; +import Tag from '../tags/helpers/Tag'; import SearchField from '../utils/SearchField'; import { listShortUrls } from './reducers/shortUrlsList'; import './SearchBar.scss'; diff --git a/src/short-urls/helpers/EditTagsModal.js b/src/short-urls/helpers/EditTagsModal.js index 38a95949..c3832bb5 100644 --- a/src/short-urls/helpers/EditTagsModal.js +++ b/src/short-urls/helpers/EditTagsModal.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import PropTypes from 'prop-types'; import { pick } from 'ramda'; -import TagsSelector from '../../utils/TagsSelector'; +import TagsSelector from '../../tags/helpers/TagsSelector'; import { editShortUrlTags, resetShortUrlsTags, diff --git a/src/short-urls/helpers/ShortUrlsRow.js b/src/short-urls/helpers/ShortUrlsRow.js index 0fbd9e82..f9d64420 100644 --- a/src/short-urls/helpers/ShortUrlsRow.js +++ b/src/short-urls/helpers/ShortUrlsRow.js @@ -2,7 +2,7 @@ import { isEmpty } from 'ramda'; import React from 'react'; import Moment from 'react-moment'; import PropTypes from 'prop-types'; -import Tag from '../../utils/Tag'; +import Tag from '../../tags/helpers/Tag'; import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams'; import { serverType } from '../../servers/prop-types'; import ExternalLink from '../../utils/ExternalLink'; diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js index 3222a37d..8121e570 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -5,7 +5,7 @@ import editIcon from '@fortawesome/fontawesome-free-solid/faPencilAlt'; import PropTypes from 'prop-types'; import React from 'react'; import { Link } from 'react-router-dom'; -import TagBullet from '../utils/TagBullet'; +import TagBullet from './helpers/TagBullet'; import './TagCard.scss'; import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal'; import EditTagModal from './helpers/EditTagModal'; diff --git a/src/utils/Tag.js b/src/tags/helpers/Tag.js similarity index 91% rename from src/utils/Tag.js rename to src/tags/helpers/Tag.js index f22787ce..a08085a9 100644 --- a/src/utils/Tag.js +++ b/src/tags/helpers/Tag.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator'; +import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator'; import './Tag.scss'; const propTypes = { diff --git a/src/utils/Tag.scss b/src/tags/helpers/Tag.scss similarity index 100% rename from src/utils/Tag.scss rename to src/tags/helpers/Tag.scss diff --git a/src/utils/TagBullet.js b/src/tags/helpers/TagBullet.js similarity index 86% rename from src/utils/TagBullet.js rename to src/tags/helpers/TagBullet.js index ccdc4801..1427613a 100644 --- a/src/utils/TagBullet.js +++ b/src/tags/helpers/TagBullet.js @@ -1,6 +1,6 @@ import React from 'react'; import * as PropTypes from 'prop-types'; -import colorGenerator, { colorGeneratorType } from './ColorGenerator'; +import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator'; import './TagBullet.scss'; const propTypes = { diff --git a/src/utils/TagBullet.scss b/src/tags/helpers/TagBullet.scss similarity index 100% rename from src/utils/TagBullet.scss rename to src/tags/helpers/TagBullet.scss diff --git a/src/utils/TagsSelector.js b/src/tags/helpers/TagsSelector.js similarity index 95% rename from src/utils/TagsSelector.js rename to src/tags/helpers/TagsSelector.js index 141947f9..4a218448 100644 --- a/src/utils/TagsSelector.js +++ b/src/tags/helpers/TagsSelector.js @@ -4,8 +4,8 @@ import TagsInput from 'react-tagsinput'; import PropTypes from 'prop-types'; import Autosuggest from 'react-autosuggest'; import { pick, identity } from 'ramda'; -import { listTags } from '../tags/reducers/tagsList'; -import colorGenerator, { colorGeneratorType } from './ColorGenerator'; +import { listTags } from '../reducers/tagsList'; +import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator'; import './TagsSelector.scss'; import TagBullet from './TagBullet'; diff --git a/src/utils/TagsSelector.scss b/src/tags/helpers/TagsSelector.scss similarity index 90% rename from src/utils/TagsSelector.scss rename to src/tags/helpers/TagsSelector.scss index 24a68c8e..aba09e1b 100644 --- a/src/utils/TagsSelector.scss +++ b/src/tags/helpers/TagsSelector.scss @@ -1,4 +1,4 @@ -@import './base'; +@import '../../utils/base'; .react-autosuggest__suggestions-list { list-style-type: none; From b7ca32ff8fafcdb5b3c0df450facece760af4378 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Sep 2018 10:33:16 +0200 Subject: [PATCH 06/24] Moved visits-related elements to visits folder --- src/common/MenuLayout.js | 2 +- src/reducers/index.js | 2 +- src/{short-urls => visits}/ShortUrlVisits.js | 10 +++++----- src/{short-urls => visits}/ShortUrlVisits.scss | 0 src/{short-urls => visits}/reducers/shortUrlVisits.js | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) rename src/{short-urls => visits}/ShortUrlVisits.js (98%) rename src/{short-urls => visits}/ShortUrlVisits.scss (100%) rename src/{short-urls => visits}/reducers/shortUrlVisits.js (96%) diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index a42b1d49..60e33a31 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -8,7 +8,7 @@ import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars'; import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import classnames from 'classnames'; import * as PropTypes from 'prop-types'; -import ShortUrlsVisits from '../short-urls/ShortUrlVisits'; +import ShortUrlsVisits from '../visits/ShortUrlVisits'; import { selectServer } from '../servers/reducers/selectedServer'; import CreateShortUrl from '../short-urls/CreateShortUrl'; import ShortUrls from '../short-urls/ShortUrls'; diff --git a/src/reducers/index.js b/src/reducers/index.js index 33a26e8b..aa087212 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -4,7 +4,7 @@ import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult'; -import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits'; +import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; diff --git a/src/short-urls/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js similarity index 98% rename from src/short-urls/ShortUrlVisits.js rename to src/visits/ShortUrlVisits.js index 99e67687..90ea51b7 100644 --- a/src/short-urls/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -8,16 +8,16 @@ import { connect } from 'react-redux'; import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap'; import PropTypes from 'prop-types'; import DateInput from '../common/DateInput'; +import MutedMessage from '../utils/MuttedMessage'; +import ExternalLink from '../utils/ExternalLink'; +import { serverType } from '../servers/prop-types/index'; +import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; import { processOsStats, processBrowserStats, processCountriesStats, processReferrersStats, -} from '../visits/services/VisitsParser'; -import MutedMessage from '../utils/MuttedMessage'; -import ExternalLink from '../utils/ExternalLink'; -import { serverType } from '../servers/prop-types'; -import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; +} from './services/VisitsParser'; import './ShortUrlVisits.scss'; export class ShortUrlsVisitsComponent extends React.Component { diff --git a/src/short-urls/ShortUrlVisits.scss b/src/visits/ShortUrlVisits.scss similarity index 100% rename from src/short-urls/ShortUrlVisits.scss rename to src/visits/ShortUrlVisits.scss diff --git a/src/short-urls/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js similarity index 96% rename from src/short-urls/reducers/shortUrlVisits.js rename to src/visits/reducers/shortUrlVisits.js index 65db1162..b3daf133 100644 --- a/src/short-urls/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,7 +1,7 @@ import { curry } from 'ramda'; import PropTypes from 'prop-types'; import shlinkApiClient from '../../api/ShlinkApiClient'; -import { shortUrlType } from './shortUrlsList'; +import { shortUrlType } from '../../short-urls/reducers/shortUrlsList'; /* eslint-disable padding-line-between-statements, newline-after-var */ const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; From 0d97c084c209ee5bf5edec44f13e7e8c4ff59781 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Sep 2018 11:08:27 +0200 Subject: [PATCH 07/24] Extracted components in ShortUrlVisits to simplify maintainability --- src/visits/GraphCard.js | 48 +++++++++++++++ src/visits/ShortUrlVisits.js | 116 ++++++----------------------------- src/visits/VisitsHeader.js | 53 ++++++++++++++++ src/visits/VisitsHeader.scss | 3 + 4 files changed, 124 insertions(+), 96 deletions(-) create mode 100644 src/visits/GraphCard.js create mode 100644 src/visits/VisitsHeader.js create mode 100644 src/visits/VisitsHeader.scss diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js new file mode 100644 index 00000000..52a87436 --- /dev/null +++ b/src/visits/GraphCard.js @@ -0,0 +1,48 @@ +import { Card, CardHeader, CardBody } from 'reactstrap'; +import { Doughnut, HorizontalBar } from 'react-chartjs-2'; +import PropTypes from 'prop-types'; +import React from 'react'; + +const propTypes = { + title: PropTypes.string, + isBarChart: PropTypes.bool, + stats: PropTypes.object, +}; + +export function GraphCard({ title, isBarChart, stats }) { + const generateGraphData = (stats) => ({ + labels: Object.keys(stats), + datasets: [ + { + title, + data: Object.values(stats), + backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ + '#97BBCD', + '#DCDCDC', + '#F7464A', + '#46BFBD', + '#FDB45C', + '#949FB1', + '#4D5360', + ], + borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white', + borderWidth: 2, + }, + ], + }); + const renderGraph = () => { + const Component = isBarChart ? HorizontalBar : Doughnut; + const legend = isBarChart ? { display: false } : { position: 'right' }; + + return ; + }; + + return ( + + {title} + {renderGraph()} + + ); +} + +GraphCard.propTypes = propTypes; diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 90ea51b7..7a5aaef7 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -2,23 +2,22 @@ import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch'; import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import { isEmpty, mapObjIndexed, pick } from 'ramda'; import React from 'react'; -import { Doughnut, HorizontalBar } from 'react-chartjs-2'; -import Moment from 'react-moment'; import { connect } from 'react-redux'; -import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap'; +import { Card } from 'reactstrap'; import PropTypes from 'prop-types'; import DateInput from '../common/DateInput'; import MutedMessage from '../utils/MuttedMessage'; -import ExternalLink from '../utils/ExternalLink'; import { serverType } from '../servers/prop-types/index'; import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; import { - processOsStats, processBrowserStats, processCountriesStats, + processOsStats, processReferrersStats, } from './services/VisitsParser'; import './ShortUrlVisits.scss'; +import { VisitsHeader } from './VisitsHeader'; +import { GraphCard } from './GraphCard'; export class ShortUrlsVisitsComponent extends React.Component { static propTypes = { @@ -60,59 +59,12 @@ export class ShortUrlsVisitsComponent extends React.Component { processBrowserStats, processCountriesStats, processReferrersStats, - shortUrlVisits: { visits, loading, error, shortUrl }, + shortUrlVisits, } = this.props; + const { visits, loading, error } = shortUrlVisits; const serverUrl = selectedServer ? selectedServer.url : ''; const shortLink = `${serverUrl}/${params.shortCode}`; - const generateGraphData = (stats, label, isBarChart) => ({ - labels: Object.keys(stats), - datasets: [ - { - label, - data: Object.values(stats), - backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ - '#97BBCD', - '#DCDCDC', - '#F7464A', - '#46BFBD', - '#FDB45C', - '#949FB1', - '#4D5360', - ], - borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white', - borderWidth: 2, - }, - ], - }); - const renderGraphCard = (title, stats, isBarChart, label) => ( -
- - {title} - - {!isBarChart && ( - - )} - {isBarChart && ( - - )} - - -
- ); + const renderContent = () => { if (loading) { return Loading...; @@ -132,53 +84,25 @@ export class ShortUrlsVisitsComponent extends React.Component { return (
- {renderGraphCard('Operating systems', processOsStats(visits), false)} - {renderGraphCard('Browsers', processBrowserStats(visits), false)} - {renderGraphCard('Countries', processCountriesStats(visits), true, 'Visits')} - {renderGraphCard('Referrers', processReferrersStats(visits), true, 'Visits')} +
+ +
+
+ +
+
+ +
+
+ +
); }; - const renderCreated = () => ( - - {shortUrl.dateCreated} - - {shortUrl.dateCreated} - - - ); - return (
-
- - -

- { - shortUrl.visitsCount && - Visits: {shortUrl.visitsCount} - } - Visit stats for {shortLink} -

-
- {shortUrl.dateCreated && ( -
- Created: -   - {loading && Loading...} - {!loading && renderCreated()} -
- )} -
- Long URL: -   - {loading && Loading...} - {!loading && {shortUrl.longUrl}} -
-
-
-
+
diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js new file mode 100644 index 00000000..30dbd412 --- /dev/null +++ b/src/visits/VisitsHeader.js @@ -0,0 +1,53 @@ +import { Card, UncontrolledTooltip } from 'reactstrap'; +import Moment from 'react-moment'; +import React from 'react'; +import PropTypes from 'prop-types'; +import ExternalLink from '../utils/ExternalLink'; +import { shortUrlVisitsType } from './reducers/shortUrlVisits'; +import './VisitsHeader.scss'; + +const propTypes = { + shortUrlVisits: shortUrlVisitsType, + shortLink: PropTypes.string, +}; + +export function VisitsHeader({ shortUrlVisits, shortLink }) { + const { shortUrl, loading } = shortUrlVisits; + const renderDate = () => ( + + {shortUrl.dateCreated} + + {shortUrl.dateCreated} + + + ); + + return ( +
+ +

+ {shortUrl.visitsCount && + Visits: {shortUrl.visitsCount}} + Visit stats for {shortLink} +

+
+ {shortUrl.dateCreated && ( +
+ Created: +   + {loading && Loading...} + {!loading && renderDate()} +
+ )} +
+ Long URL: +   + {loading && Loading...} + {!loading && {shortUrl.longUrl}} +
+
+
+ ); +} + +VisitsHeader.propTypes = propTypes; diff --git a/src/visits/VisitsHeader.scss b/src/visits/VisitsHeader.scss new file mode 100644 index 00000000..51dcc29f --- /dev/null +++ b/src/visits/VisitsHeader.scss @@ -0,0 +1,3 @@ +.visits-header__created-at { + cursor: default; +} From 8b17ff88ed79ed20f5f1da7df6afcad6ac2279cf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Sep 2018 11:26:35 +0200 Subject: [PATCH 08/24] Split short URL visits reducer into two individual reducers --- src/reducers/index.js | 6 ++- src/visits/ShortUrlVisits.js | 26 ++++++++---- src/visits/VisitsHeader.js | 8 ++-- src/visits/reducers/shortUrlDetail.js | 60 +++++++++++++++++++++++++++ src/visits/reducers/shortUrlVisits.js | 20 ++++----- 5 files changed, 94 insertions(+), 26 deletions(-) create mode 100644 src/visits/reducers/shortUrlDetail.js diff --git a/src/reducers/index.js b/src/reducers/index.js index aa087212..54929232 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -4,8 +4,9 @@ import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult'; -import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; +import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; +import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; import tagEditReducer from '../tags/reducers/tagEdit'; @@ -16,8 +17,9 @@ export default combineReducers({ shortUrlsList: shortUrlsListReducer, shortUrlsListParams: shortUrlsListParamsReducer, shortUrlCreationResult: shortUrlCreationResultReducer, - shortUrlVisits: shortUrlVisitsReducer, shortUrlTags: shortUrlTagsReducer, + shortUrlVisits: shortUrlVisitsReducer, + shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, tagEdit: tagEditReducer, diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 7a5aaef7..42ec802f 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -15,9 +15,10 @@ import { processOsStats, processReferrersStats, } from './services/VisitsParser'; -import './ShortUrlVisits.scss'; import { VisitsHeader } from './VisitsHeader'; import { GraphCard } from './GraphCard'; +import { getShortUrlDetail, shortUrlDetailType } from './reducers/shortUrlDetail'; +import './ShortUrlVisits.scss'; export class ShortUrlsVisitsComponent extends React.Component { static propTypes = { @@ -26,9 +27,11 @@ export class ShortUrlsVisitsComponent extends React.Component { processCountriesStats: PropTypes.func, processReferrersStats: PropTypes.func, match: PropTypes.object, - getShortUrlVisits: PropTypes.func, selectedServer: serverType, + getShortUrlVisits: PropTypes.func, shortUrlVisits: shortUrlVisitsType, + getShortUrlDetail: PropTypes.func, + shortUrlDetail: shortUrlDetailType, }; static defaultProps = { processOsStats, @@ -48,7 +51,10 @@ export class ShortUrlsVisitsComponent extends React.Component { }; componentDidMount() { + const { match: { params }, getShortUrlDetail } = this.props; + this.loadVisits(); + getShortUrlDetail(params.shortCode); } render() { @@ -60,12 +66,14 @@ export class ShortUrlsVisitsComponent extends React.Component { processCountriesStats, processReferrersStats, shortUrlVisits, + shortUrlDetail, } = this.props; - const { visits, loading, error } = shortUrlVisits; const serverUrl = selectedServer ? selectedServer.url : ''; const shortLink = `${serverUrl}/${params.shortCode}`; - const renderContent = () => { + const renderVisitsContent = () => { + const { visits, loading, error } = shortUrlVisits; + if (loading) { return Loading...; } @@ -79,7 +87,7 @@ export class ShortUrlsVisitsComponent extends React.Component { } if (isEmpty(visits)) { - return There have been no visits matching current filter :(; + return There are no visits matching current filter :(; } return ( @@ -102,7 +110,7 @@ export class ShortUrlsVisitsComponent extends React.Component { return (
- +
@@ -127,7 +135,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
- {renderContent()} + {renderVisitsContent()}
); @@ -135,8 +143,8 @@ export class ShortUrlsVisitsComponent extends React.Component { } const ShortUrlsVisits = connect( - pick([ 'selectedServer', 'shortUrlVisits' ]), - { getShortUrlVisits } + pick([ 'selectedServer', 'shortUrlVisits', 'shortUrlDetail' ]), + { getShortUrlVisits, getShortUrlDetail } )(ShortUrlsVisitsComponent); export default ShortUrlsVisits; diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index 30dbd412..cb932409 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -3,16 +3,16 @@ import Moment from 'react-moment'; import React from 'react'; import PropTypes from 'prop-types'; import ExternalLink from '../utils/ExternalLink'; -import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import './VisitsHeader.scss'; +import { shortUrlDetailType } from './reducers/shortUrlDetail'; const propTypes = { - shortUrlVisits: shortUrlVisitsType, + shortUrlDetail: shortUrlDetailType, shortLink: PropTypes.string, }; -export function VisitsHeader({ shortUrlVisits, shortLink }) { - const { shortUrl, loading } = shortUrlVisits; +export function VisitsHeader({ shortUrlDetail, shortLink }) { + const { shortUrl, loading } = shortUrlDetail; const renderDate = () => ( {shortUrl.dateCreated} diff --git a/src/visits/reducers/shortUrlDetail.js b/src/visits/reducers/shortUrlDetail.js new file mode 100644 index 00000000..e1a44261 --- /dev/null +++ b/src/visits/reducers/shortUrlDetail.js @@ -0,0 +1,60 @@ +import { curry } from 'ramda'; +import PropTypes from 'prop-types'; +import shlinkApiClient from '../../api/ShlinkApiClient'; +import { shortUrlType } from '../../short-urls/reducers/shortUrlsList'; + +/* eslint-disable padding-line-between-statements, newline-after-var */ +const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; +const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR'; +const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; +/* eslint-enable padding-line-between-statements, newline-after-var */ + +export const shortUrlDetailType = PropTypes.shape({ + shortUrl: shortUrlType, + loading: PropTypes.bool, + error: PropTypes.bool, +}); + +const initialState = { + shortUrl: {}, + loading: false, + error: false, +}; + +export default function reducer(state = initialState, action) { + switch (action.type) { + case GET_SHORT_URL_DETAIL_START: + return { + ...state, + loading: true, + }; + case GET_SHORT_URL_DETAIL_ERROR: + return { + ...state, + loading: false, + error: true, + }; + case GET_SHORT_URL_DETAIL: + return { + shortUrl: action.shortUrl, + loading: false, + error: false, + }; + default: + return state; + } +} + +export const _getShortUrlDetail = (shlinkApiClient, shortCode) => async (dispatch) => { + dispatch({ type: GET_SHORT_URL_DETAIL_START }); + + try { + const shortUrl = await shlinkApiClient.getShortUrl(shortCode); + + dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL }); + } catch (e) { + dispatch({ type: GET_SHORT_URL_DETAIL_ERROR }); + } +}; + +export const getShortUrlDetail = curry(_getShortUrlDetail)(shlinkApiClient); diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index b3daf133..72d89a3e 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,7 +1,6 @@ import { curry } from 'ramda'; import PropTypes from 'prop-types'; import shlinkApiClient from '../../api/ShlinkApiClient'; -import { shortUrlType } from '../../short-urls/reducers/shortUrlsList'; /* eslint-disable padding-line-between-statements, newline-after-var */ const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -10,7 +9,6 @@ const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; /* eslint-enable padding-line-between-statements, newline-after-var */ export const shortUrlVisitsType = PropTypes.shape({ - shortUrl: shortUrlType, visits: PropTypes.array, loading: PropTypes.bool, error: PropTypes.bool, @@ -23,7 +21,7 @@ const initialState = { error: false, }; -export default function dispatch(state = initialState, action) { +export default function reducer(state = initialState, action) { switch (action.type) { case GET_SHORT_URL_VISITS_START: return { @@ -38,7 +36,6 @@ export default function dispatch(state = initialState, action) { }; case GET_SHORT_URL_VISITS: return { - shortUrl: action.shortUrl, visits: action.visits, loading: false, error: false, @@ -48,15 +45,16 @@ export default function dispatch(state = initialState, action) { } } -export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => (dispatch) => { +export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => async (dispatch) => { dispatch({ type: GET_SHORT_URL_VISITS_START }); - Promise.all([ - shlinkApiClient.getShortUrlVisits(shortCode, dates), - shlinkApiClient.getShortUrl(shortCode), - ]) - .then(([ visits, shortUrl ]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS })) - .catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR })); + try { + const visits = await shlinkApiClient.getShortUrlVisits(shortCode, dates); + + dispatch({ visits, type: GET_SHORT_URL_VISITS }); + } catch (e) { + dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); + } }; export const getShortUrlVisits = curry(_getShortUrlVisits)(shlinkApiClient); From 28bd39f97431f17a55d89ff3031cd86ffd6c5bf4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Sep 2018 11:37:58 +0200 Subject: [PATCH 09/24] Fixed stylelint not properly inspecting files in src root --- package.json | 2 +- src/index.scss | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 55c84580..a33bb691 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "lint": "yarn lint:js && yarn lint:css", "lint:js": "eslint src test scripts config", "lint:js:fix": "yarn lint:js --fix", - "lint:css": "stylelint src/**/*.scss", + "lint:css": "stylelint src/*.scss src/**/*.scss", "lint:css:fix": "yarn lint:css --fix", "start": "node scripts/start.js", "build": "node scripts/build.js", diff --git a/src/index.scss b/src/index.scss index dfeabf0a..e12c6afa 100644 --- a/src/index.scss +++ b/src/index.scss @@ -3,7 +3,7 @@ html, body, #root { - height: 100% + height: 100%; } * { @@ -21,6 +21,7 @@ body, .dropdown-item { cursor: pointer; } + .dropdown-item.active, .dropdown-item:active { @extend .bg-main; @@ -46,7 +47,6 @@ body, .navbar-brand { @media (max-width: $smMax) { - margin-right: auto !important; // This is needed to override a third party style - margin: 0 auto; + margin: 0 auto !important; } } From f84e3c5b609e55ae6108dea733358f6356956d29 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Sep 2018 11:45:42 +0200 Subject: [PATCH 10/24] Moved jest config from package.json to external file --- jest.config.js | 33 +++++++++++++++++++++++++++++++++ package.json | 35 ----------------------------------- 2 files changed, 33 insertions(+), 35 deletions(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..55b1480b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,33 @@ +module.exports = { + coverageDirectory: '/coverage', + collectCoverageFrom: [ + 'src/**/*.{js,jsx,mjs}', + '!src/registerServiceWorker.js', + '!src/index.js', + ], + setupFiles: [ + '/config/polyfills.js', + '/config/setupEnzyme.js', + ], + testMatch: [ '/test/**/*.test.{js,jsx,mjs}' ], + testEnvironment: 'node', + testURL: 'http://localhost', + transform: { + '^.+\\.(js|jsx|mjs)$': '/node_modules/babel-jest', + '^.+\\.css$': '/config/jest/cssTransform.js', + '^(?!.*\\.(js|jsx|mjs|css|json)$)': '/config/jest/fileTransform.js', + }, + transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$' ], + moduleNameMapper: { + '^react-native$': 'react-native-web', + }, + moduleFileExtensions: [ + 'web.js', + 'js', + 'json', + 'web.jsx', + 'jsx', + 'node', + 'mjs', + ], +}; diff --git a/package.json b/package.json index a33bb691..c90544e0 100644 --- a/package.json +++ b/package.json @@ -102,41 +102,6 @@ "webpack-manifest-plugin": "1.3.2", "whatwg-fetch": "2.0.3" }, - "jest": { - "coverageDirectory": "/coverage", - "collectCoverageFrom": [ - "src/**/*.{js,jsx,mjs}" - ], - "setupFiles": [ - "/config/polyfills.js", - "/config/setupEnzyme.js" - ], - "testMatch": [ - "/test/**/*.test.{js,jsx,mjs}" - ], - "testEnvironment": "node", - "testURL": "http://localhost", - "transform": { - "^.+\\.(js|jsx|mjs)$": "/node_modules/babel-jest", - "^.+\\.css$": "/config/jest/cssTransform.js", - "^(?!.*\\.(js|jsx|mjs|css|json)$)": "/config/jest/fileTransform.js" - }, - "transformIgnorePatterns": [ - "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$" - ], - "moduleNameMapper": { - "^react-native$": "react-native-web" - }, - "moduleFileExtensions": [ - "web.js", - "js", - "json", - "web.jsx", - "jsx", - "node", - "mjs" - ] - }, "babel": { "presets": [ "react-app" From f0b0fdf11494d8557a5db239547c726168c8d3a5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 2 Sep 2018 08:05:04 +0200 Subject: [PATCH 11/24] Replaced hardcoded paypal link by short link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b2b1940..23ec2cfa 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master) [![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest) [![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE) -[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alejandrocelaya%40gmail.com¤cy_code=EUR) +[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://acel.me/donate) A ReactJS-based progressive web application for [Shlink](https://shlink.io). From 3e63734e2b6f729d07fe4205a83b911494ff6e9d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Sep 2018 20:17:46 +0200 Subject: [PATCH 12/24] Improved visits page --- src/visits/ShortUrlVisits.js | 4 +++- src/visits/VisitsHeader.js | 13 +++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 42ec802f..cf0c09f9 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -110,7 +110,7 @@ export class ShortUrlsVisitsComponent extends React.Component { return (
- +
@@ -119,6 +119,7 @@ export class ShortUrlsVisitsComponent extends React.Component { selected={this.state.startDate} placeholderText="Since" isClearable + maxDate={this.state.endDate} onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())} />
@@ -128,6 +129,7 @@ export class ShortUrlsVisitsComponent extends React.Component { placeholderText="Until" isClearable className="short-url-visits__date-input" + minDate={this.state.startDate} onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())} />
diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index cb932409..db0d3e5e 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -5,14 +5,17 @@ import PropTypes from 'prop-types'; import ExternalLink from '../utils/ExternalLink'; import './VisitsHeader.scss'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; +import { shortUrlVisitsType } from './reducers/shortUrlVisits'; const propTypes = { - shortUrlDetail: shortUrlDetailType, + shortUrlDetail: shortUrlDetailType.isRequired, + shortUrlVisits: shortUrlVisitsType.isRequired, shortLink: PropTypes.string, }; -export function VisitsHeader({ shortUrlDetail, shortLink }) { +export function VisitsHeader({ shortUrlDetail, shortUrlVisits, shortLink }) { const { shortUrl, loading } = shortUrlDetail; + const { visits } = shortUrlVisits; const renderDate = () => ( {shortUrl.dateCreated} @@ -26,8 +29,7 @@ export function VisitsHeader({ shortUrlDetail, shortLink }) {

- {shortUrl.visitsCount && - Visits: {shortUrl.visitsCount}} + Visits: {visits.length} Visit stats for {shortLink}


@@ -35,8 +37,7 @@ export function VisitsHeader({ shortUrlDetail, shortLink }) {
Created:   - {loading && Loading...} - {!loading && renderDate()} + {renderDate()}
)}
From bbce53ade6c7465d52b4039207459d4d0147b12b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 7 Sep 2018 20:41:21 +0200 Subject: [PATCH 13/24] Created shortUrlDetail reducer test --- .eslintrc | 1 + src/visits/reducers/shortUrlDetail.js | 6 +- .../reducers/shortUrlsListParams.test.js | 2 +- test/visits/reducers/shortUrlDetail.test.js | 92 +++++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 test/visits/reducers/shortUrlDetail.test.js diff --git a/.eslintrc b/.eslintrc index 7207fac3..7ac3f4ec 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,6 +2,7 @@ "extends": [ "adidas-env/browser", "adidas-env/module", + "adidas-env/node", "adidas-es6", "adidas-babel", "adidas-react" diff --git a/src/visits/reducers/shortUrlDetail.js b/src/visits/reducers/shortUrlDetail.js index e1a44261..cb86e040 100644 --- a/src/visits/reducers/shortUrlDetail.js +++ b/src/visits/reducers/shortUrlDetail.js @@ -4,9 +4,9 @@ import shlinkApiClient from '../../api/ShlinkApiClient'; import { shortUrlType } from '../../short-urls/reducers/shortUrlsList'; /* eslint-disable padding-line-between-statements, newline-after-var */ -const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; -const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR'; -const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; +export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; +export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR'; +export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; /* eslint-enable padding-line-between-statements, newline-after-var */ export const shortUrlDetailType = PropTypes.shape({ diff --git a/test/short-urls/reducers/shortUrlsListParams.test.js b/test/short-urls/reducers/shortUrlsListParams.test.js index 848691bc..334fdb71 100644 --- a/test/short-urls/reducers/shortUrlsListParams.test.js +++ b/test/short-urls/reducers/shortUrlsListParams.test.js @@ -5,7 +5,7 @@ import reducer, { import { LIST_SHORT_URLS } from '../../../src/short-urls/reducers/shortUrlsList'; describe('shortUrlsListParamsReducer', () => { - describe('reducerr', () => { + describe('reducer', () => { const defaultState = { page: '1' }; it('returns default value when action is unknown', () => diff --git a/test/visits/reducers/shortUrlDetail.test.js b/test/visits/reducers/shortUrlDetail.test.js new file mode 100644 index 00000000..d192e48f --- /dev/null +++ b/test/visits/reducers/shortUrlDetail.test.js @@ -0,0 +1,92 @@ +import * as sinon from 'sinon'; +import reducer, { + _getShortUrlDetail, + GET_SHORT_URL_DETAIL_START, + GET_SHORT_URL_DETAIL_ERROR, + GET_SHORT_URL_DETAIL, +} from '../../../src/visits/reducers/shortUrlDetail'; + +describe('shortUrlDetailReducer', () => { + describe('reducer', () => { + it('returns loading on GET_SHORT_URL_DETAIL_START', () => { + const state = reducer({ loading: false }, { type: GET_SHORT_URL_DETAIL_START }); + const { loading } = state; + + expect(loading).toEqual(true); + }); + + it('stops loading and returns error on GET_SHORT_URL_DETAIL_ERROR', () => { + const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_DETAIL_ERROR }); + const { loading, error } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(true); + }); + + it('return short URL on GET_SHORT_URL_DETAIL', () => { + const actionShortUrl = { longUrl: 'foo', shortCode: 'bar' }; + const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_DETAIL, shortUrl: actionShortUrl }); + const { loading, error, shortUrl } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(false); + expect(shortUrl).toEqual(actionShortUrl); + }); + + it('returns default state on unknown action', () => { + const defaultState = { + shortUrl: {}, + loading: false, + error: false, + }; + const state = reducer(defaultState, { type: 'unknown' }); + + expect(state).toEqual(defaultState); + }); + }); + + describe('getShortUrlDetail', () => { + const buildApiClientMock = (returned) => ({ + getShortUrl: sinon.fake.returns(returned), + }); + const dispatchMock = sinon.spy(); + + beforeEach(() => dispatchMock.resetHistory()); + + it('dispatches start and error when promise is rejected', async () => { + const ShlinkApiClient = buildApiClientMock(Promise.reject()); + const expectedDispatchCalls = 2; + + await _getShortUrlDetail(ShlinkApiClient, 'abc123')(dispatchMock); + + const [ firstCallArg ] = dispatchMock.getCall(0).args; + const { type: firstCallType } = firstCallArg; + + const [ secondCallArg ] = dispatchMock.getCall(1).args; + const { type: secondCallType } = secondCallArg; + + expect(dispatchMock.callCount).toEqual(expectedDispatchCalls); + expect(firstCallType).toEqual(GET_SHORT_URL_DETAIL_START); + expect(secondCallType).toEqual(GET_SHORT_URL_DETAIL_ERROR); + }); + + it('dispatches start and success when promise is resolved', async () => { + const resolvedShortUrl = { longUrl: 'foo', shortCode: 'bar' }; + const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedShortUrl)); + const expectedDispatchCalls = 2; + + await _getShortUrlDetail(ShlinkApiClient, 'abc123')(dispatchMock); + + const [ firstCallArg ] = dispatchMock.getCall(0).args; + const { type: firstCallType } = firstCallArg; + + const [ secondCallArg ] = dispatchMock.getCall(1).args; + const { type: secondCallType, shortUrl } = secondCallArg; + + expect(dispatchMock.callCount).toEqual(expectedDispatchCalls); + expect(firstCallType).toEqual(GET_SHORT_URL_DETAIL_START); + expect(secondCallType).toEqual(GET_SHORT_URL_DETAIL); + expect(shortUrl).toEqual(resolvedShortUrl); + }); + }); +}); From 0c1656285b30ad787cf9dad4b394de3aaeea6c82 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Sep 2018 08:49:49 +0200 Subject: [PATCH 14/24] Created shortUrlVisits reducer test --- src/visits/reducers/shortUrlVisits.js | 7 +- test/visits/reducers/shortUrlDetail.test.js | 2 + test/visits/reducers/shortUrlVisits.test.js | 94 +++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 test/visits/reducers/shortUrlVisits.test.js diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index 72d89a3e..1bb724ab 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.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 GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; -const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR'; -const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; +export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; +export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR'; +export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; /* eslint-enable padding-line-between-statements, newline-after-var */ export const shortUrlVisitsType = PropTypes.shape({ @@ -15,7 +15,6 @@ export const shortUrlVisitsType = PropTypes.shape({ }); const initialState = { - shortUrl: {}, visits: [], loading: false, error: false, diff --git a/test/visits/reducers/shortUrlDetail.test.js b/test/visits/reducers/shortUrlDetail.test.js index d192e48f..8fac8e71 100644 --- a/test/visits/reducers/shortUrlDetail.test.js +++ b/test/visits/reducers/shortUrlDetail.test.js @@ -66,6 +66,7 @@ describe('shortUrlDetailReducer', () => { const { type: secondCallType } = secondCallArg; expect(dispatchMock.callCount).toEqual(expectedDispatchCalls); + expect(ShlinkApiClient.getShortUrl.callCount).toEqual(1); expect(firstCallType).toEqual(GET_SHORT_URL_DETAIL_START); expect(secondCallType).toEqual(GET_SHORT_URL_DETAIL_ERROR); }); @@ -84,6 +85,7 @@ describe('shortUrlDetailReducer', () => { const { type: secondCallType, shortUrl } = secondCallArg; expect(dispatchMock.callCount).toEqual(expectedDispatchCalls); + expect(ShlinkApiClient.getShortUrl.callCount).toEqual(1); expect(firstCallType).toEqual(GET_SHORT_URL_DETAIL_START); expect(secondCallType).toEqual(GET_SHORT_URL_DETAIL); expect(shortUrl).toEqual(resolvedShortUrl); diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js new file mode 100644 index 00000000..04888841 --- /dev/null +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -0,0 +1,94 @@ +import * as sinon from 'sinon'; +import reducer, { + _getShortUrlVisits, + GET_SHORT_URL_VISITS_START, + GET_SHORT_URL_VISITS_ERROR, + GET_SHORT_URL_VISITS, +} from '../../../src/visits/reducers/shortUrlVisits'; + +describe('shortUrlVisitsReducer', () => { + describe('reducer', () => { + it('returns loading on GET_SHORT_URL_VISITS_START', () => { + const state = reducer({ loading: false }, { type: GET_SHORT_URL_VISITS_START }); + const { loading } = state; + + expect(loading).toEqual(true); + }); + + it('stops loading and returns error on GET_SHORT_URL_VISITS_ERROR', () => { + const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_VISITS_ERROR }); + const { loading, error } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(true); + }); + + it('return visits on GET_SHORT_URL_VISITS', () => { + const actionVisits = [{}, {}]; + const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_VISITS, visits: actionVisits }); + const { loading, error, visits } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(false); + expect(visits).toEqual(actionVisits); + }); + + it('returns default state on unknown action', () => { + const defaultState = { + visits: [], + loading: false, + error: false, + }; + const state = reducer(defaultState, { type: 'unknown' }); + + expect(state).toEqual(defaultState); + }); + }); + + describe('getShortUrlVisits', () => { + const buildApiClientMock = (returned) => ({ + getShortUrlVisits: sinon.fake.returns(returned), + }); + const dispatchMock = sinon.spy(); + + beforeEach(() => dispatchMock.resetHistory()); + + it('dispatches start and error when promise is rejected', async () => { + const ShlinkApiClient = buildApiClientMock(Promise.reject()); + const expectedDispatchCalls = 2; + + await _getShortUrlVisits(ShlinkApiClient, 'abc123')(dispatchMock); + + const [ firstCallArg ] = dispatchMock.getCall(0).args; + const { type: firstCallType } = firstCallArg; + + const [ secondCallArg ] = dispatchMock.getCall(1).args; + const { type: secondCallType } = secondCallArg; + + expect(dispatchMock.callCount).toEqual(expectedDispatchCalls); + expect(ShlinkApiClient.getShortUrlVisits.callCount).toEqual(1); + expect(firstCallType).toEqual(GET_SHORT_URL_VISITS_START); + expect(secondCallType).toEqual(GET_SHORT_URL_VISITS_ERROR); + }); + + it('dispatches start and success when promise is resolved', async () => { + const resolvedVisits = [{}, {}]; + const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedVisits)); + const expectedDispatchCalls = 2; + + await _getShortUrlVisits(ShlinkApiClient, 'abc123')(dispatchMock); + + const [ firstCallArg ] = dispatchMock.getCall(0).args; + const { type: firstCallType } = firstCallArg; + + const [ secondCallArg ] = dispatchMock.getCall(1).args; + const { type: secondCallType, visits } = secondCallArg; + + expect(dispatchMock.callCount).toEqual(expectedDispatchCalls); + expect(ShlinkApiClient.getShortUrlVisits.callCount).toEqual(1); + expect(firstCallType).toEqual(GET_SHORT_URL_VISITS_START); + expect(secondCallType).toEqual(GET_SHORT_URL_VISITS); + expect(visits).toEqual(resolvedVisits); + }); + }); +}); From eb0f219403cea1498bb6e8bc888d4f221263d2d7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Sep 2018 09:06:18 +0200 Subject: [PATCH 15/24] Created GraphCard test --- src/visits/GraphCard.js | 5 +-- test/visits/GraphCard.test.js | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 test/visits/GraphCard.test.js diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js index 52a87436..26acc8bc 100644 --- a/src/visits/GraphCard.js +++ b/src/visits/GraphCard.js @@ -2,6 +2,7 @@ import { Card, CardHeader, CardBody } from 'reactstrap'; import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import PropTypes from 'prop-types'; import React from 'react'; +import { keys, values } from 'ramda'; const propTypes = { title: PropTypes.string, @@ -11,11 +12,11 @@ const propTypes = { export function GraphCard({ title, isBarChart, stats }) { const generateGraphData = (stats) => ({ - labels: Object.keys(stats), + labels: keys(stats), datasets: [ { title, - data: Object.values(stats), + data: values(stats), backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ '#97BBCD', '#DCDCDC', diff --git a/test/visits/GraphCard.test.js b/test/visits/GraphCard.test.js new file mode 100644 index 00000000..fd03c0a9 --- /dev/null +++ b/test/visits/GraphCard.test.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Doughnut, HorizontalBar } from 'react-chartjs-2'; +import { keys, values } from 'ramda'; +import { GraphCard } from '../../src/visits/GraphCard'; + +describe('', () => { + let wrapper; + const stats = { + foo: 123, + bar: 456, + }; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it('renders Doughnut when is not a bar chart', () => { + wrapper = shallow(); + const doughnut = wrapper.find(Doughnut); + const horizontal = wrapper.find(HorizontalBar); + + expect(doughnut).toHaveLength(1); + expect(horizontal).toHaveLength(0); + + const { labels, datasets: [{ title, data, backgroundColor, borderColor }] } = doughnut.prop('data'); + + expect(title).toEqual('The chart'); + expect(labels).toEqual(keys(stats)); + expect(data).toEqual(values(stats)); + expect(backgroundColor).toEqual([ + '#97BBCD', + '#DCDCDC', + '#F7464A', + '#46BFBD', + '#FDB45C', + '#949FB1', + '#4D5360', + ]); + expect(borderColor).toEqual('white'); + }); + + it('renders HorizontalBar when is not a bar chart', () => { + wrapper = shallow(); + const doughnut = wrapper.find(Doughnut); + const horizontal = wrapper.find(HorizontalBar); + + expect(doughnut).toHaveLength(0); + expect(horizontal).toHaveLength(1); + + const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data'); + + expect(backgroundColor).toEqual('rgba(70, 150, 229, 0.4)'); + expect(borderColor).toEqual('rgba(70, 150, 229, 1)'); + }); +}); From d37e7ca7ce92479b2640e05f91d21b652afc4915 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Sep 2018 09:31:44 +0200 Subject: [PATCH 16/24] Created VisitsHeader test --- src/utils/ExternalLink.js | 2 +- src/visits/ShortUrlVisits.js | 10 ++------ src/visits/VisitsHeader.js | 11 ++++---- test/visits/VisitsHeader.test.js | 44 ++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 test/visits/VisitsHeader.test.js diff --git a/src/utils/ExternalLink.js b/src/utils/ExternalLink.js index f926db7e..916809cd 100644 --- a/src/utils/ExternalLink.js +++ b/src/utils/ExternalLink.js @@ -11,7 +11,7 @@ export default function ExternalLink(props) { return ( - {children} + {children || href} ); } diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index cf0c09f9..eea3edf7 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -7,7 +7,6 @@ import { Card } from 'reactstrap'; import PropTypes from 'prop-types'; import DateInput from '../common/DateInput'; import MutedMessage from '../utils/MuttedMessage'; -import { serverType } from '../servers/prop-types/index'; import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; import { processBrowserStats, @@ -27,7 +26,6 @@ export class ShortUrlsVisitsComponent extends React.Component { processCountriesStats: PropTypes.func, processReferrersStats: PropTypes.func, match: PropTypes.object, - selectedServer: serverType, getShortUrlVisits: PropTypes.func, shortUrlVisits: shortUrlVisitsType, getShortUrlDetail: PropTypes.func, @@ -59,8 +57,6 @@ export class ShortUrlsVisitsComponent extends React.Component { render() { const { - match: { params }, - selectedServer, processOsStats, processBrowserStats, processCountriesStats, @@ -68,8 +64,6 @@ export class ShortUrlsVisitsComponent extends React.Component { shortUrlVisits, shortUrlDetail, } = this.props; - const serverUrl = selectedServer ? selectedServer.url : ''; - const shortLink = `${serverUrl}/${params.shortCode}`; const renderVisitsContent = () => { const { visits, loading, error } = shortUrlVisits; @@ -110,7 +104,7 @@ export class ShortUrlsVisitsComponent extends React.Component { return (
- +
@@ -145,7 +139,7 @@ export class ShortUrlsVisitsComponent extends React.Component { } const ShortUrlsVisits = connect( - pick([ 'selectedServer', 'shortUrlVisits', 'shortUrlDetail' ]), + pick([ 'shortUrlVisits', 'shortUrlDetail' ]), { getShortUrlVisits, getShortUrlDetail } )(ShortUrlsVisitsComponent); diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index db0d3e5e..6213ced2 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -1,7 +1,6 @@ import { Card, UncontrolledTooltip } from 'reactstrap'; import Moment from 'react-moment'; import React from 'react'; -import PropTypes from 'prop-types'; import ExternalLink from '../utils/ExternalLink'; import './VisitsHeader.scss'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; @@ -10,12 +9,14 @@ import { shortUrlVisitsType } from './reducers/shortUrlVisits'; const propTypes = { shortUrlDetail: shortUrlDetailType.isRequired, shortUrlVisits: shortUrlVisitsType.isRequired, - shortLink: PropTypes.string, }; -export function VisitsHeader({ shortUrlDetail, shortUrlVisits, shortLink }) { +export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) { const { shortUrl, loading } = shortUrlDetail; const { visits } = shortUrlVisits; + const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; + const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : ''; + const renderDate = () => ( {shortUrl.dateCreated} @@ -30,7 +31,7 @@ export function VisitsHeader({ shortUrlDetail, shortUrlVisits, shortLink }) {

Visits: {visits.length} - Visit stats for {shortLink} + Visit stats for


{shortUrl.dateCreated && ( @@ -44,7 +45,7 @@ export function VisitsHeader({ shortUrlDetail, shortUrlVisits, shortLink }) { Long URL:   {loading && Loading...} - {!loading && {shortUrl.longUrl}} + {!loading && }
diff --git a/test/visits/VisitsHeader.test.js b/test/visits/VisitsHeader.test.js new file mode 100644 index 00000000..5416d3cb --- /dev/null +++ b/test/visits/VisitsHeader.test.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Moment from 'react-moment'; +import { VisitsHeader } from '../../src/visits/VisitsHeader'; +import ExternalLink from '../../src/utils/ExternalLink'; + +describe('', () => { + let wrapper; + const shortUrlDetail = { + shortUrl: { + longUrl: 'https://foo.bar/bar/foo', + dateCreated: '2018-01-01T10:00:00+01:00', + }, + loading: false, + }; + const shortUrlVisits = { + visits: [{}, {}, {}], + }; + + beforeEach(() => { + wrapper = shallow( + + ); + }); + afterEach(() => wrapper.unmount()); + + it('shows the amount of visits', () => { + const visitsBadge = wrapper.find('.badge'); + + expect(visitsBadge.text()).toEqual(`Visits: ${shortUrlVisits.visits.length}`); + }); + + it('shows when the URL was created', () => { + const moment = wrapper.find(Moment).first(); + + expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl.dateCreated); + }); + + it('shows the long URL', () => { + const longUrlLink = wrapper.find(ExternalLink).last(); + + expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl.longUrl); + }); +}); From 64c1b56973dcdbf02d7378b5bad169291c82e0af Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Sep 2018 13:28:40 +0200 Subject: [PATCH 17/24] Created ShortUrlVisits test --- src/common/DateInput.js | 8 +-- src/visits/ShortUrlVisits.js | 5 +- test/visits/ShortUrlVisits.test.js | 89 ++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 test/visits/ShortUrlVisits.test.js diff --git a/src/common/DateInput.js b/src/common/DateInput.js index 2981810a..a6586b47 100644 --- a/src/common/DateInput.js +++ b/src/common/DateInput.js @@ -2,24 +2,24 @@ import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt'; import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import React from 'react'; import DatePicker from 'react-datepicker'; -import './DateInput.scss'; import { isNil } from 'ramda'; +import './DateInput.scss'; export default class DateInput extends React.Component { constructor(props) { super(props); - this.inputRef = React.createRef(); + this.inputRef = props.ref || React.createRef(); } render() { - const { isClearable, selected } = this.props; + const { className, isClearable, selected } = this.props; const showCalendarIcon = !isClearable || isNil(selected); return (
this.setState({ endDate: date }, () => this.loadVisits())} /> diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js new file mode 100644 index 00000000..d7d2a3e8 --- /dev/null +++ b/test/visits/ShortUrlVisits.test.js @@ -0,0 +1,89 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { identity } from 'ramda'; +import { Card } from 'reactstrap'; +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'; + +describe('', () => { + let wrapper; + const statsProcessor = () => ({}); + const getShortUrlVisitsMock = sinon.spy(); + const match = { + params: { shortCode: 'abc123' }, + }; + + const createComponent = (shortUrlVisits) => { + wrapper = shallow( + + ); + + return wrapper; + }; + + afterEach(() => { + getShortUrlVisitsMock.resetHistory(); + + if (wrapper) { + wrapper.unmount(); + } + }); + + it('Renders a preloader when visits are loading', () => { + const wrapper = createComponent({ loading: true }); + const loadingMessage = wrapper.find(MutedMessage); + + expect(loadingMessage).toHaveLength(1); + expect(loadingMessage.html()).toContain('Loading...'); + }); + + it('renders an error message when visits could not be loaded', () => { + const wrapper = createComponent({ loading: false, error: true }); + const errorMessage = wrapper.find(Card); + + expect(errorMessage).toHaveLength(1); + expect(errorMessage.html()).toContain('An error occurred while loading visits :('); + }); + + it('renders a message when visits are loaded but the list is empty', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [] }); + const message = wrapper.find(MutedMessage); + + expect(message).toHaveLength(1); + expect(message.html()).toContain('There are no visits matching current filter :('); + }); + + it('renders all graphics when visits are properly loaded', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const graphs = wrapper.find(GraphCard); + const expectedGraphsCount = 4; + + expect(graphs).toHaveLength(expectedGraphsCount); + }); + + it('reloads visits when selected dates change', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const dateInput = wrapper.find(DateInput).first(); + const expectedGetShortUrlVisitsCalls = 4; + + dateInput.simulate('change', '2016-01-01T00:00:00+01:00'); + dateInput.simulate('change', '2016-01-02T00:00:00+01:00'); + dateInput.simulate('change', '2016-01-03T00:00:00+01:00'); + + expect(getShortUrlVisitsMock.callCount).toEqual(expectedGetShortUrlVisitsCalls); + expect(wrapper.state('startDate')).toEqual('2016-01-03T00:00:00+01:00'); + }); +}); From 5d5a2be4988b74de70f553c2000cc4cfee6ca091 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Sep 2018 20:34:04 +0200 Subject: [PATCH 18/24] Ensured bar charts start at 0 --- src/visits/GraphCard.js | 13 +++++++++++-- test/visits/GraphCard.test.js | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js index 26acc8bc..e1a814da 100644 --- a/src/visits/GraphCard.js +++ b/src/visits/GraphCard.js @@ -33,9 +33,18 @@ export function GraphCard({ title, isBarChart, stats }) { }); const renderGraph = () => { const Component = isBarChart ? HorizontalBar : Doughnut; - const legend = isBarChart ? { display: false } : { position: 'right' }; + const options = { + legend: isBarChart ? { display: false } : { position: 'right' }, + scales: isBarChart ? { + xAxes: [ + { + ticks: { beginAtZero: true }, + }, + ], + } : null, + }; - return ; + return ; }; return ( diff --git a/test/visits/GraphCard.test.js b/test/visits/GraphCard.test.js index fd03c0a9..50d2ae14 100644 --- a/test/visits/GraphCard.test.js +++ b/test/visits/GraphCard.test.js @@ -26,6 +26,7 @@ describe('', () => { expect(horizontal).toHaveLength(0); const { labels, datasets: [{ title, data, backgroundColor, borderColor }] } = doughnut.prop('data'); + const { legend, scales } = doughnut.prop('options'); expect(title).toEqual('The chart'); expect(labels).toEqual(keys(stats)); @@ -40,6 +41,8 @@ describe('', () => { '#4D5360', ]); expect(borderColor).toEqual('white'); + expect(legend).toEqual({ position: 'right' }); + expect(scales).toBeNull(); }); it('renders HorizontalBar when is not a bar chart', () => { @@ -51,8 +54,17 @@ describe('', () => { expect(horizontal).toHaveLength(1); const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data'); + const { legend, scales } = horizontal.prop('options'); expect(backgroundColor).toEqual('rgba(70, 150, 229, 0.4)'); expect(borderColor).toEqual('rgba(70, 150, 229, 1)'); + expect(legend).toEqual({ display: false }); + expect(scales).toEqual({ + xAxes: [ + { + ticks: { beginAtZero: true }, + }, + ], + }); }); }); From 9b3bfe56bb29276807389c21b13c77b6c40e9175 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Sep 2018 09:01:05 +0200 Subject: [PATCH 19/24] Ensured list filtering params are reset when list component unmounts --- src/short-urls/ShortUrlsList.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 43335d7f..40c96ad3 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -11,7 +11,7 @@ import { serverType } from '../servers/prop-types'; import { ShortUrlsRow } from './helpers/ShortUrlsRow'; import { listShortUrls, shortUrlType } from './reducers/shortUrlsList'; import './ShortUrlsList.scss'; -import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; +import { shortUrlsListParamsType, resetShortUrlParams } from './reducers/shortUrlsListParams'; const SORTABLE_FIELDS = { dateCreated: 'Created at', @@ -23,6 +23,7 @@ const SORTABLE_FIELDS = { export class ShortUrlsListComponent extends React.Component { static propTypes = { listShortUrls: PropTypes.func, + resetShortUrlParams: PropTypes.func, shortUrlsListParams: shortUrlsListParamsType, match: PropTypes.object, location: PropTypes.object, @@ -89,6 +90,12 @@ export class ShortUrlsListComponent extends React.Component { this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags }); } + componentWillUnmount() { + const { resetShortUrlParams } = this.props; + + resetShortUrlParams(); + } + renderShortUrls() { const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props; @@ -188,7 +195,7 @@ export class ShortUrlsListComponent extends React.Component { const ShortUrlsList = connect( pick([ 'selectedServer', 'shortUrlsListParams' ]), - { listShortUrls } + { listShortUrls, resetShortUrlParams } )(ShortUrlsListComponent); export default ShortUrlsList; From 2d6dda3576e57c79765e59cd7403cf3c3314ac8b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Sep 2018 09:35:39 +0200 Subject: [PATCH 20/24] Created delete short URLs modal --- src/index.scss | 3 +- src/short-urls/helpers/DeleteShortUrlModal.js | 52 +++++++++++++++++++ src/short-urls/helpers/ShortUrlsRowMenu.js | 23 ++++++-- src/short-urls/helpers/ShortUrlsRowMenu.scss | 13 +++++ 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 src/short-urls/helpers/DeleteShortUrlModal.js diff --git a/src/index.scss b/src/index.scss index e12c6afa..f9f7bd22 100644 --- a/src/index.scss +++ b/src/index.scss @@ -24,7 +24,8 @@ body, .dropdown-item.active, .dropdown-item:active { - @extend .bg-main; + background-color: $lightGrey !important; + color: inherit !important; } .shlink-container { diff --git a/src/short-urls/helpers/DeleteShortUrlModal.js b/src/short-urls/helpers/DeleteShortUrlModal.js new file mode 100644 index 00000000..9580329f --- /dev/null +++ b/src/short-urls/helpers/DeleteShortUrlModal.js @@ -0,0 +1,52 @@ +import React, { Component } from 'react'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import PropTypes from 'prop-types'; +import { shortUrlType } from '../reducers/shortUrlsList'; +import './QrCodeModal.scss'; + +export default class DeleteShortUrlModal extends Component { + static propTypes = { + shortUrl: shortUrlType, + toggle: PropTypes.func, + isOpen: PropTypes.bool, + }; + + state = { inputValue: '' }; + + render() { + const { shortUrl, toggle, isOpen } = this.props; + + return ( + +
e.preventDefault()}> + + Delete short URL + + +

Caution! You are about to delete a short URL.

+

This action cannot be undone. Once you have deleted it, all the visits stats will be lost.

+ + this.setState({ inputValue: e.target.value })} + /> +
+ + + + +
+
+ ); + } +} diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.js b/src/short-urls/helpers/ShortUrlsRowMenu.js index c835a17b..bc39f8f0 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.js +++ b/src/short-urls/helpers/ShortUrlsRowMenu.js @@ -4,6 +4,7 @@ import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags'; import pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie'; import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV'; import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode'; +import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle'; import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import React from 'react'; import { CopyToClipboard } from 'react-copy-to-clipboard'; @@ -16,6 +17,7 @@ import PreviewModal from './PreviewModal'; import QrCodeModal from './QrCodeModal'; import './ShortUrlsRowMenu.scss'; import EditTagsModal from './EditTagsModal'; +import DeleteShortUrlModal from './DeleteShortUrlModal'; export class ShortUrlsRowMenu extends React.Component { static propTypes = { @@ -30,15 +32,18 @@ export class ShortUrlsRowMenu extends React.Component { isQrModalOpen: false, isPreviewOpen: false, isTagsModalOpen: false, + isDeleteModalOpen: false, }; toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen })); render() { const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props; const serverId = selectedServer ? selectedServer.id : ''; - const toggleQrCode = () => this.setState(({ isQrModalOpen }) => ({ isQrModalOpen: !isQrModalOpen })); - const togglePreview = () => this.setState(({ isPreviewOpen }) => ({ isPreviewOpen: !isPreviewOpen })); - const toggleTags = () => this.setState(({ isTagsModalOpen }) => ({ isTagsModalOpen: !isTagsModalOpen })); + const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] })); + const toggleQrCode = toggleModal('isQrModalOpen'); + const togglePreview = toggleModal('isPreviewOpen'); + const toggleTags = toggleModal('isTagsModalOpen'); + const toggleDelete = toggleModal('isDeleteModalOpen'); return ( @@ -47,8 +52,9 @@ export class ShortUrlsRowMenu extends React.Component { -  Visit Stats +  Visit stats +  Edit tags @@ -59,6 +65,15 @@ export class ShortUrlsRowMenu extends React.Component { toggle={toggleTags} /> + +  Delete short URL + + + diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.scss b/src/short-urls/helpers/ShortUrlsRowMenu.scss index 9e9aa579..b0799fe8 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.scss +++ b/src/short-urls/helpers/ShortUrlsRowMenu.scss @@ -1,6 +1,19 @@ +@import '../../utils/base'; + .short-urls-row-menu__dropdown-toggle:before { display: none !important; } + .short-urls-row-menu__dropdown-toggle--hidden { visibility: hidden; } + +.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger { + color: $dangerColor; + + &:hover, + &:active, + &.active { + color: $dangerColor !important; + } +} From f2d03203ae176a2e9c4121567aa8fdcd6b8bfcb4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Sep 2018 10:47:17 +0200 Subject: [PATCH 21/24] Implemented short URLs deletion --- src/api/ShlinkApiClient.js | 5 ++ src/reducers/index.js | 6 +- src/short-urls/CreateShortUrl.js | 2 +- .../helpers/CreateShortUrlResult.js | 2 +- src/short-urls/helpers/DeleteShortUrlModal.js | 61 +++++++++++++-- ...lCreationResult.js => shortUrlCreation.js} | 0 src/short-urls/reducers/shortUrlDeletion.js | 76 +++++++++++++++++++ src/short-urls/reducers/shortUrlsList.js | 9 ++- 8 files changed, 150 insertions(+), 11 deletions(-) rename src/short-urls/reducers/{shortUrlCreationResult.js => shortUrlCreation.js} (100%) create mode 100644 src/short-urls/reducers/shortUrlDeletion.js diff --git a/src/api/ShlinkApiClient.js b/src/api/ShlinkApiClient.js index 9355ac81..6c35b84a 100644 --- a/src/api/ShlinkApiClient.js +++ b/src/api/ShlinkApiClient.js @@ -44,6 +44,11 @@ export class ShlinkApiClient { .then((resp) => resp.data) .catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ])); + deleteShortUrl = (shortCode) => + this._performRequest(`/short-codes/${shortCode}`, 'DELETE') + .then(() => ({})) + .catch((e) => this._handleAuthError(e, this.deleteShortUrl, [ shortCode ])); + updateShortUrlTags = (shortCode, tags) => this._performRequest(`/short-codes/${shortCode}/tags`, 'PUT', {}, { tags }) .then((resp) => resp.data.tags) diff --git a/src/reducers/index.js b/src/reducers/index.js index 54929232..1fdba738 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -3,7 +3,8 @@ import serversReducer from '../servers/reducers/server'; import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; -import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult'; +import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; +import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; @@ -16,7 +17,8 @@ export default combineReducers({ selectedServer: selectedServerReducer, shortUrlsList: shortUrlsListReducer, shortUrlsListParams: shortUrlsListParamsReducer, - shortUrlCreationResult: shortUrlCreationResultReducer, + shortUrlCreationResult: shortUrlCreationReducer, + shortUrlDeletion: shortUrlDeletionReducer, shortUrlTags: shortUrlTagsReducer, shortUrlVisits: shortUrlVisitsReducer, shortUrlDetail: shortUrlDetailReducer, diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index 5d1bfde6..e3c65819 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -8,7 +8,7 @@ import { Collapse } from 'reactstrap'; import DateInput from '../common/DateInput'; import TagsSelector from '../tags/helpers/TagsSelector'; import CreateShortUrlResult from './helpers/CreateShortUrlResult'; -import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult'; +import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreation'; export class CreateShortUrlComponent extends React.Component { state = { diff --git a/src/short-urls/helpers/CreateShortUrlResult.js b/src/short-urls/helpers/CreateShortUrlResult.js index 5dd2aae4..d8264d03 100644 --- a/src/short-urls/helpers/CreateShortUrlResult.js +++ b/src/short-urls/helpers/CreateShortUrlResult.js @@ -5,7 +5,7 @@ import React from 'react'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import { Card, CardBody, Tooltip } from 'reactstrap'; import PropTypes from 'prop-types'; -import { createShortUrlResultType } from '../reducers/shortUrlCreationResult'; +import { createShortUrlResultType } from '../reducers/shortUrlCreation'; import { stateFlagTimeout } from '../../utils/utils'; import './CreateShortUrlResult.scss'; diff --git a/src/short-urls/helpers/DeleteShortUrlModal.js b/src/short-urls/helpers/DeleteShortUrlModal.js index 9580329f..7eca1e04 100644 --- a/src/short-urls/helpers/DeleteShortUrlModal.js +++ b/src/short-urls/helpers/DeleteShortUrlModal.js @@ -1,24 +1,56 @@ import React, { Component } from 'react'; +import { connect } from 'react-redux'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import PropTypes from 'prop-types'; +import { pick, identity } from 'ramda'; import { shortUrlType } from '../reducers/shortUrlsList'; +import { + deleteShortUrl, + resetDeleteShortUrl, + shortUrlDeleted, + shortUrlDeletionType, +} from '../reducers/shortUrlDeletion'; import './QrCodeModal.scss'; -export default class DeleteShortUrlModal extends Component { +export class DeleteShortUrlModalComponent extends Component { static propTypes = { shortUrl: shortUrlType, toggle: PropTypes.func, isOpen: PropTypes.bool, + shortUrlDeletion: shortUrlDeletionType, + deleteShortUrl: PropTypes.func, + resetDeleteShortUrl: PropTypes.func, + shortUrlDeleted: PropTypes.func, }; state = { inputValue: '' }; + handleDeleteUrl = (e) => { + e.preventDefault(); + + const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props; + const { shortCode } = shortUrl; + + deleteShortUrl(shortCode) + .then(() => { + shortUrlDeleted(shortCode); + toggle(); + }) + .catch(identity); + }; + + componentWillUnmount() { + const { resetDeleteShortUrl } = this.props; + + resetDeleteShortUrl(); + } render() { - const { shortUrl, toggle, isOpen } = this.props; + const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props; + const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION'; return ( -
e.preventDefault()}> + Delete short URL @@ -33,16 +65,26 @@ export default class DeleteShortUrlModal extends Component { value={this.state.inputValue} onChange={(e) => this.setState({ inputValue: e.target.value })} /> + + {shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED && ( +
+ This short URL has received too many visits and therefore, it cannot be deleted +
+ )} + {shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED && ( +
+ Something went wrong while deleting the URL :( +
+ )}
@@ -50,3 +92,10 @@ export default class DeleteShortUrlModal extends Component { ); } } + +const DeleteShortUrlModal = connect( + pick([ 'shortUrlDeletion' ]), + { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } +)(DeleteShortUrlModalComponent); + +export default DeleteShortUrlModal; diff --git a/src/short-urls/reducers/shortUrlCreationResult.js b/src/short-urls/reducers/shortUrlCreation.js similarity index 100% rename from src/short-urls/reducers/shortUrlCreationResult.js rename to src/short-urls/reducers/shortUrlCreation.js diff --git a/src/short-urls/reducers/shortUrlDeletion.js b/src/short-urls/reducers/shortUrlDeletion.js new file mode 100644 index 00000000..60cdedbe --- /dev/null +++ b/src/short-urls/reducers/shortUrlDeletion.js @@ -0,0 +1,76 @@ +import { curry } from 'ramda'; +import PropTypes from 'prop-types'; +import shlinkApiClient from '../../api/ShlinkApiClient'; + +/* 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 SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED'; +/* eslint-enable padding-line-between-statements, newline-after-var */ + +export const shortUrlDeletionType = PropTypes.shape({ + shortCode: PropTypes.string.isRequired, + loading: PropTypes.bool.isRequired, + error: PropTypes.bool.isRequired, + errorData: PropTypes.shape({ + error: PropTypes.string, + message: PropTypes.string, + }).isRequired, +}); + +const defaultState = { + shortCode: '', + loading: false, + error: false, + errorData: {}, +}; + +export default function reducer(state = defaultState, action) { + switch (action.type) { + case DELETE_SHORT_URL_START: + return { + ...state, + loading: true, + error: false, + }; + case DELETE_SHORT_URL_ERROR: + return { + ...state, + loading: false, + error: true, + errorData: action.errorData, + }; + case DELETE_SHORT_URL: + return { + ...state, + shortCode: action.shortCode, + loading: false, + error: false, + }; + case RESET_DELETE_SHORT_URL: + return defaultState; + default: + return state; + } +} + +export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch) => { + dispatch({ type: DELETE_SHORT_URL_START }); + + try { + await shlinkApiClient.deleteShortUrl(shortCode); + dispatch({ type: DELETE_SHORT_URL, shortCode }); + } catch (e) { + dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data }); + + throw e; + } +}; + +export const deleteShortUrl = curry(_deleteShortUrl)(shlinkApiClient); + +export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL }); + +export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode }); diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index 1a66bd4e..95e4a7fc 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -1,7 +1,8 @@ -import { assoc, assocPath } from 'ramda'; +import { assoc, assocPath, reject } from 'ramda'; import PropTypes from 'prop-types'; import shlinkApiClient from '../../api/ShlinkApiClient'; 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'; @@ -43,6 +44,12 @@ export default function reducer(state = initialState, action) { shortUrl.shortCode === action.shortCode ? assoc('tags', action.tags, shortUrl) : shortUrl), state); + case SHORT_URL_DELETED: + return assocPath( + [ 'shortUrls', 'data' ], + reject((shortUrl) => shortUrl.shortCode === action.shortCode, state.shortUrls.data), + state, + ); default: return state; } From fc1af042432831c4245b3adf4d61ef00c3ae09f0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Sep 2018 12:18:02 +0200 Subject: [PATCH 22/24] Created tests for Paginator and SearchBar --- src/short-urls/Paginator.js | 8 ++--- test/short-urls/Paginator.test.js | 32 +++++++++++++++++ test/short-urls/SearchBar.test.js | 59 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 test/short-urls/Paginator.test.js create mode 100644 test/short-urls/SearchBar.test.js diff --git a/src/short-urls/Paginator.js b/src/short-urls/Paginator.js index 893e2da8..3890c27b 100644 --- a/src/short-urls/Paginator.js +++ b/src/short-urls/Paginator.js @@ -20,13 +20,13 @@ export default function Paginator({ paginator = {}, serverId }) { } const renderPages = () => - range(1, pagesCount + 1).map((i) => ( - + range(1, pagesCount + 1).map((pageNumber) => ( + - {i} + {pageNumber} )); diff --git a/test/short-urls/Paginator.test.js b/test/short-urls/Paginator.test.js new file mode 100644 index 00000000..f4893c41 --- /dev/null +++ b/test/short-urls/Paginator.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { PaginationItem } from 'reactstrap'; +import Paginator from '../../src/short-urls/Paginator'; + +describe('', () => { + let wrapper; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it('renders nothing if the number of pages is below 2', () => { + wrapper = shallow(); + expect(wrapper.text()).toEqual(''); + }); + + it('renders previous, next and the list of pages', () => { + const paginator = { + currentPage: 1, + pagesCount: 5, + }; + const extraPagesPrevNext = 2; + const expectedItems = paginator.pagesCount + extraPagesPrevNext; + + wrapper = shallow(); + + expect(wrapper.find(PaginationItem)).toHaveLength(expectedItems); + }); +}); diff --git a/test/short-urls/SearchBar.test.js b/test/short-urls/SearchBar.test.js new file mode 100644 index 00000000..75bc122e --- /dev/null +++ b/test/short-urls/SearchBar.test.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import sinon from 'sinon'; +import { SearchBarComponent } from '../../src/short-urls/SearchBar'; +import SearchField from '../../src/utils/SearchField'; +import Tag from '../../src/tags/helpers/Tag'; + +describe('', () => { + let wrapper; + const listShortUrlsMock = sinon.spy(); + + afterEach(() => { + listShortUrlsMock.resetHistory(); + + if (wrapper) { + wrapper.unmount(); + } + }); + + it('renders a SearchField', () => { + wrapper = shallow(); + + expect(wrapper.find(SearchField)).toHaveLength(1); + }); + + it('renders no tags when the list of tags is empty', () => { + wrapper = shallow(); + + expect(wrapper.find(Tag)).toHaveLength(0); + }); + + it('renders the proper amount of tags', () => { + const tags = [ 'foo', 'bar', 'baz' ]; + + wrapper = shallow(); + + expect(wrapper.find(Tag)).toHaveLength(tags.length); + }); + + it('updates short URLs list when search field changes', () => { + wrapper = shallow(); + const searchField = wrapper.find(SearchField); + + expect(listShortUrlsMock.callCount).toEqual(0); + searchField.simulate('change'); + expect(listShortUrlsMock.callCount).toEqual(1); + }); + + it('updates short URLs list when a tag is removed', () => { + wrapper = shallow( + + ); + const tag = wrapper.find(Tag).first(); + + expect(listShortUrlsMock.callCount).toEqual(0); + tag.simulate('close'); + expect(listShortUrlsMock.callCount).toEqual(1); + }); +}); From 7d665f3933f981e447a77836dfc95e660662ece0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Sep 2018 12:48:28 +0200 Subject: [PATCH 23/24] Ensured tags returned from server are used after editing short URL tags in order to use the normalized values --- src/short-urls/helpers/EditTagsModal.js | 4 ++-- src/short-urls/reducers/shortUrlTags.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/short-urls/helpers/EditTagsModal.js b/src/short-urls/helpers/EditTagsModal.js index c3832bb5..83d94470 100644 --- a/src/short-urls/helpers/EditTagsModal.js +++ b/src/short-urls/helpers/EditTagsModal.js @@ -40,8 +40,8 @@ export class EditTagsModalComponent extends React.Component { return; } - const { shortUrlTagsEdited, shortUrl } = this.props; - const { tags } = this.state; + const { shortUrlTagsEdited, shortUrl, shortUrlTags } = this.props; + const { tags } = shortUrlTags; shortUrlTagsEdited(shortUrl.shortCode, tags); }; diff --git a/src/short-urls/reducers/shortUrlTags.js b/src/short-urls/reducers/shortUrlTags.js index a24fb7ee..597c5a89 100644 --- a/src/short-urls/reducers/shortUrlTags.js +++ b/src/short-urls/reducers/shortUrlTags.js @@ -56,9 +56,9 @@ export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (di dispatch({ type: EDIT_SHORT_URL_TAGS_START }); try { - // Update short URL tags - await shlinkApiClient.updateShortUrlTags(shortCode, tags); - dispatch({ tags, shortCode, type: EDIT_SHORT_URL_TAGS }); + const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags); + + dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS }); } catch (e) { dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR }); From bc8eaaaee456387b8f5c887beb99f9e3495a135b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Sep 2018 12:51:53 +0200 Subject: [PATCH 24/24] Added v1.1.0 to changelog --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2767d612..e4306c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # CHANGELOG +## 1.1.0 - 2018-09-16 + +#### Added + +* [#47](https://github.com/shlinkio/shlink-web-client/issues/47) Added support to delete short URLs (requires [shlink v1.12.0](https://github.com/shlinkio/shlink/releases/tag/v1.12.0) or greater). + +#### Changed + +* [#35](https://github.com/shlinkio/shlink-web-client/issues/35) Visits component split into two, which makes the header not to be refreshed when filtering by date, and also the visits global counter now reflects the actual number of visits which fulfill current filter. +* [#36](https://github.com/shlinkio/shlink-web-client/issues/36) Tags selector now autocompletes existing tag names, to prevent typos and ease reusing existing tags. +* [#39](https://github.com/shlinkio/shlink-web-client/issues/39) Defined `propTypes` as static properties in class components. + +#### Deprecated + +* *Nothing* + +#### Removed + +* *Nothing* + +#### Fixed + +* [#49](https://github.com/shlinkio/shlink-web-client/issues/49) Ensured filtering parameters are reseted when list component is unmounted so that params are not mixed when coming back. +* [#45](https://github.com/shlinkio/shlink-web-client/issues/45) Ensured graphs x-axis start at `0` and don't use decimals. +* [#51](https://github.com/shlinkio/shlink-web-client/issues/51) When editing short URL tags, the value returned form server is used when refreshing the list, which is normalized. + + ## 1.0.1 - 2018-09-02 #### Added @@ -33,7 +60,7 @@ * Export all servers in a CSV file. * Import the CSV in a different device. -* [#4](https://github.com/shlinkio/shlink-web-client/issues/4) Added tags management. +* [#3](https://github.com/shlinkio/shlink-web-client/issues/3) Added tags management. * List existing tags, and filter the list. * Change their name and color.