diff --git a/.eslintrc b/.eslintrc index 8839bdef..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" @@ -35,6 +36,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/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. diff --git a/README.md b/README.md index 88727204..23ec2cfa 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://acel.me/donate) A ReactJS-based progressive web application for [Shlink](https://shlink.io). 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 9280a206..c90544e0 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", @@ -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", @@ -101,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" 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/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(({ 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..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'; @@ -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/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/index.scss b/src/index.scss index dfeabf0a..f9f7bd22 100644 --- a/src/index.scss +++ b/src/index.scss @@ -3,7 +3,7 @@ html, body, #root { - height: 100% + height: 100%; } * { @@ -21,9 +21,11 @@ body, .dropdown-item { cursor: pointer; } + .dropdown-item.active, .dropdown-item:active { - @extend .bg-main; + background-color: $lightGrey !important; + color: inherit !important; } .shlink-container { @@ -46,7 +48,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; } } diff --git a/src/reducers/index.js b/src/reducers/index.js index 33a26e8b..1fdba738 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -3,9 +3,11 @@ 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 shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits'; +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'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; import tagEditReducer from '../tags/reducers/tagEdit'; @@ -15,9 +17,11 @@ export default combineReducers({ selectedServer: selectedServerReducer, shortUrlsList: shortUrlsListReducer, shortUrlsListParams: shortUrlsListParamsReducer, - shortUrlCreationResult: shortUrlCreationResultReducer, - shortUrlVisits: shortUrlVisitsReducer, + shortUrlCreationResult: shortUrlCreationReducer, + shortUrlDeletion: shortUrlDeletionReducer, shortUrlTags: shortUrlTagsReducer, + shortUrlVisits: shortUrlVisitsReducer, + shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, tagEdit: tagEditReducer, 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/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index 3db73243..e3c65819 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -6,9 +6,9 @@ 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'; +import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreation'; export class CreateShortUrlComponent extends React.Component { state = { 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/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/ShortUrlVisits.js b/src/short-urls/ShortUrlVisits.js deleted file mode 100644 index 534026b0..00000000 --- a/src/short-urls/ShortUrlVisits.js +++ /dev/null @@ -1,221 +0,0 @@ -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 PropTypes from 'prop-types'; -import DateInput from '../common/DateInput'; -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'; -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 { - state = { startDate: undefined, endDate: undefined }; - loadVisits = () => { - const { match: { params }, getShortUrlVisits } = this.props; - - getShortUrlVisits(params.shortCode, mapObjIndexed( - (value) => value && value.format ? value.format('YYYY-MM-DD') : value, - this.state - )); - }; - - componentDidMount() { - this.loadVisits(); - } - - render() { - const { - match: { params }, - selectedServer, - processOsStats, - processBrowserStats, - processCountriesStats, - processReferrersStats, - shortUrlVisits: { visits, loading, error, shortUrl }, - } = this.props; - 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...; - } - - if (error) { - return ( - - An error occurred while loading visits :( - - ); - } - - if (isEmpty(visits)) { - return There have been no visits matching current filter :(; - } - - 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}} -
-
-
-
- -
-
-
- this.setState({ startDate: date }, () => this.loadVisits())} - /> -
-
- this.setState({ endDate: date }, () => this.loadVisits())} - /> -
-
-
- -
- {renderContent()} -
-
- ); - } -} - -ShortUrlsVisitsComponent.propTypes = propTypes; -ShortUrlsVisitsComponent.defaultProps = defaultProps; - -const ShortUrlsVisits = connect( - pick([ 'selectedServer', 'shortUrlVisits' ]), - { getShortUrlVisits } -)(ShortUrlsVisitsComponent); - -export default ShortUrlsVisits; diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index e68160e6..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', @@ -20,18 +20,19 @@ 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, + resetShortUrlParams: 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; @@ -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; @@ -186,11 +193,9 @@ export class ShortUrlsListComponent extends React.Component { } } -ShortUrlsListComponent.propTypes = propTypes; - const ShortUrlsList = connect( pick([ 'selectedServer', 'shortUrlsListParams' ]), - { listShortUrls } + { listShortUrls, resetShortUrlParams } )(ShortUrlsListComponent); export default ShortUrlsList; diff --git a/src/short-urls/helpers/CreateShortUrlResult.js b/src/short-urls/helpers/CreateShortUrlResult.js index aac34acd..d8264d03 100644 --- a/src/short-urls/helpers/CreateShortUrlResult.js +++ b/src/short-urls/helpers/CreateShortUrlResult.js @@ -5,17 +5,17 @@ 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'; -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/DeleteShortUrlModal.js b/src/short-urls/helpers/DeleteShortUrlModal.js new file mode 100644 index 00000000..7eca1e04 --- /dev/null +++ b/src/short-urls/helpers/DeleteShortUrlModal.js @@ -0,0 +1,101 @@ +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 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, shortUrlDeletion } = this.props; + const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION'; + + return ( + +
+ + 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 })} + /> + + {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 :( +
+ )} +
+ + + + +
+
+ ); + } +} + +const DeleteShortUrlModal = connect( + pick([ 'shortUrlDeletion' ]), + { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } +)(DeleteShortUrlModalComponent); + +export default DeleteShortUrlModal; diff --git a/src/short-urls/helpers/EditTagsModal.js b/src/short-urls/helpers/EditTagsModal.js index 0a8e95d0..83d94470 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, @@ -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; @@ -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); }; @@ -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..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'; @@ -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..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,29 +17,33 @@ import PreviewModal from './PreviewModal'; import QrCodeModal from './QrCodeModal'; import './ShortUrlsRowMenu.scss'; import EditTagsModal from './EditTagsModal'; - -const propTypes = { - completeShortUrl: PropTypes.string, - onCopyToClipboard: PropTypes.func, - selectedServer: serverType, - shortUrl: shortUrlType, -}; +import DeleteShortUrlModal from './DeleteShortUrlModal'; export class ShortUrlsRowMenu extends React.Component { + static propTypes = { + completeShortUrl: PropTypes.string, + onCopyToClipboard: PropTypes.func, + selectedServer: serverType, + shortUrl: shortUrlType, + }; + state = { isOpen: false, 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 + + + @@ -91,5 +106,3 @@ export class ShortUrlsRowMenu extends React.Component { ); } } - -ShortUrlsRowMenu.propTypes = propTypes; 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; + } +} 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/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 }); 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; } diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js index 2ccfd21c..8121e570 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -5,25 +5,21 @@ 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 './helpers/TagBullet'; 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, + }; + 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} @@ -69,6 +62,3 @@ export default class TagCard extends React.Component { ); } } - -TagCard.propTypes = propTypes; -TagCard.defaultProps = defaultProps; 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/TagsList.js b/src/tags/TagsList.js index c87ddfbd..b23accb3 100644 --- a/src/tags/TagsList.js +++ b/src/tags/TagsList.js @@ -4,25 +4,26 @@ 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; 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 { - componentDidMount() { - const { listTags } = this.props; + static propTypes = { + filterTags: PropTypes.func, + forceListTags: PropTypes.func, + tagsList: PropTypes.shape({ + loading: PropTypes.bool, + }), + match: PropTypes.object, + }; - listTags(); + componentDidMount() { + const { forceListTags } = this.props; + + forceListTags(true); } renderContent() { @@ -85,8 +86,6 @@ export class TagsListComponent extends React.Component { } } -TagsListComponent.propTypes = propTypes; - -const TagsList = connect(pick([ 'tagsList' ]), { listTags, filterTags })(TagsListComponent); +const TagsList = connect(pick([ 'tagsList' ]), { forceListTags, 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/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/tags/helpers/TagBullet.js b/src/tags/helpers/TagBullet.js new file mode 100644 index 00000000..1427613a --- /dev/null +++ b/src/tags/helpers/TagBullet.js @@ -0,0 +1,24 @@ +import React from 'react'; +import * as PropTypes from 'prop-types'; +import colorGenerator, { colorGeneratorType } from '../../utils/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/tags/helpers/TagBullet.scss b/src/tags/helpers/TagBullet.scss new file mode 100644 index 00000000..ad795a9d --- /dev/null +++ b/src/tags/helpers/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/tags/helpers/TagsSelector.js b/src/tags/helpers/TagsSelector.js new file mode 100644 index 00000000..4a218448 --- /dev/null +++ b/src/tags/helpers/TagsSelector.js @@ -0,0 +1,93 @@ +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 '../reducers/tagsList'; +import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator'; +import './TagsSelector.scss'; +import TagBullet from './TagBullet'; + +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', + }; + + componentDidMount() { + const { listTags } = this.props; + + listTags(); + } + + 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, ...otherProps } = data; + const handleOnChange = (e, { method }) => { + method === 'enter' ? e.preventDefault() : otherProps.onChange(e); + }; + + // eslint-disable-next-line no-extra-parens + 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) => suggestion} + renderSuggestion={(suggestion) => ( + + + {suggestion} + + )} + onSuggestionSelected={(e, { suggestion }) => { + addTag(suggestion); + }} + onSuggestionsClearRequested={identity} + onSuggestionsFetchRequested={identity} + /> + ); + }; + + return ( + + ); + } +} + +const TagsSelector = connect(pick([ 'tagsList' ]), { listTags })(TagsSelectorComponent); + +export default TagsSelector; diff --git a/src/tags/helpers/TagsSelector.scss b/src/tags/helpers/TagsSelector.scss new file mode 100644 index 00000000..aba09e1b --- /dev/null +++ b/src/tags/helpers/TagsSelector.scss @@ -0,0 +1,16 @@ +@import '../../utils/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/src/tags/reducers/tagsList.js b/src/tags/reducers/tagsList.js index c9309914..cb415902 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'; @@ -59,16 +59,20 @@ 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; } } -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 +86,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/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/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; diff --git a/src/utils/TagsSelector.js b/src/utils/TagsSelector.js deleted file mode 100644 index 35b82a37..00000000 --- a/src/utils/TagsSelector.js +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import TagsInput from 'react-tagsinput'; -import PropTypes from 'prop-types'; -import colorGenerator, { colorGeneratorType } from './ColorGenerator'; - -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 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)} />} - - ); - }; - - return ( - - ); -} - -TagsSelector.defaultProps = defaultProps; -TagsSelector.propTypes = propTypes; diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js new file mode 100644 index 00000000..e1a814da --- /dev/null +++ b/src/visits/GraphCard.js @@ -0,0 +1,58 @@ +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, + isBarChart: PropTypes.bool, + stats: PropTypes.object, +}; + +export function GraphCard({ title, isBarChart, stats }) { + const generateGraphData = (stats) => ({ + labels: keys(stats), + datasets: [ + { + title, + data: 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 options = { + legend: isBarChart ? { display: false } : { position: 'right' }, + scales: isBarChart ? { + xAxes: [ + { + ticks: { beginAtZero: true }, + }, + ], + } : null, + }; + + return ; + }; + + return ( + + {title} + {renderGraph()} + + ); +} + +GraphCard.propTypes = propTypes; diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js new file mode 100644 index 00000000..92ab0f24 --- /dev/null +++ b/src/visits/ShortUrlVisits.js @@ -0,0 +1,147 @@ +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 { connect } from 'react-redux'; +import { Card } from 'reactstrap'; +import PropTypes from 'prop-types'; +import DateInput from '../common/DateInput'; +import MutedMessage from '../utils/MuttedMessage'; +import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; +import { + processBrowserStats, + processCountriesStats, + processOsStats, + processReferrersStats, +} from './services/VisitsParser'; +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 = { + processOsStats: PropTypes.func, + processBrowserStats: PropTypes.func, + processCountriesStats: PropTypes.func, + processReferrersStats: PropTypes.func, + match: PropTypes.shape({ + params: PropTypes.object, + }), + getShortUrlVisits: PropTypes.func, + shortUrlVisits: shortUrlVisitsType, + getShortUrlDetail: PropTypes.func, + shortUrlDetail: shortUrlDetailType, + }; + static defaultProps = { + processOsStats, + processBrowserStats, + processCountriesStats, + processReferrersStats, + }; + + state = { startDate: undefined, endDate: undefined }; + loadVisits = () => { + const { match: { params }, getShortUrlVisits } = this.props; + + getShortUrlVisits(params.shortCode, mapObjIndexed( + (value) => value && value.format ? value.format('YYYY-MM-DD') : value, + this.state + )); + }; + + componentDidMount() { + const { match: { params }, getShortUrlDetail } = this.props; + + this.loadVisits(); + getShortUrlDetail(params.shortCode); + } + + render() { + const { + processOsStats, + processBrowserStats, + processCountriesStats, + processReferrersStats, + shortUrlVisits, + shortUrlDetail, + } = this.props; + + const renderVisitsContent = () => { + const { visits, loading, error } = shortUrlVisits; + + if (loading) { + return Loading...; + } + + if (error) { + return ( + + An error occurred while loading visits :( + + ); + } + + if (isEmpty(visits)) { + return There are no visits matching current filter :(; + } + + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); + }; + + return ( +
+ + +
+
+
+ this.setState({ startDate: date }, () => this.loadVisits())} + /> +
+
+ this.setState({ endDate: date }, () => this.loadVisits())} + /> +
+
+
+ +
+ {renderVisitsContent()} +
+
+ ); + } +} + +const ShortUrlsVisits = connect( + pick([ 'shortUrlVisits', 'shortUrlDetail' ]), + { getShortUrlVisits, getShortUrlDetail } +)(ShortUrlsVisitsComponent); + +export default ShortUrlsVisits; 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/visits/VisitsHeader.js b/src/visits/VisitsHeader.js new file mode 100644 index 00000000..6213ced2 --- /dev/null +++ b/src/visits/VisitsHeader.js @@ -0,0 +1,55 @@ +import { Card, UncontrolledTooltip } from 'reactstrap'; +import Moment from 'react-moment'; +import React from 'react'; +import ExternalLink from '../utils/ExternalLink'; +import './VisitsHeader.scss'; +import { shortUrlDetailType } from './reducers/shortUrlDetail'; +import { shortUrlVisitsType } from './reducers/shortUrlVisits'; + +const propTypes = { + shortUrlDetail: shortUrlDetailType.isRequired, + shortUrlVisits: shortUrlVisitsType.isRequired, +}; + +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} + + {shortUrl.dateCreated} + + + ); + + return ( +
+ +

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

+
+ {shortUrl.dateCreated && ( +
+ Created: +   + {renderDate()} +
+ )} +
+ Long URL: +   + {loading && Loading...} + {!loading && } +
+
+
+ ); +} + +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; +} diff --git a/src/visits/reducers/shortUrlDetail.js b/src/visits/reducers/shortUrlDetail.js new file mode 100644 index 00000000..cb86e040 --- /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 */ +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({ + 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/short-urls/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js similarity index 59% rename from src/short-urls/reducers/shortUrlVisits.js rename to src/visits/reducers/shortUrlVisits.js index 65db1162..1bb724ab 100644 --- a/src/short-urls/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,29 +1,26 @@ import { curry } from 'ramda'; import PropTypes from 'prop-types'; import shlinkApiClient from '../../api/ShlinkApiClient'; -import { shortUrlType } from './shortUrlsList'; /* 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({ - shortUrl: shortUrlType, visits: PropTypes.array, loading: PropTypes.bool, error: PropTypes.bool, }); const initialState = { - shortUrl: {}, visits: [], loading: false, 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 +35,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 +44,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); 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); + }); +}); 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/GraphCard.test.js b/test/visits/GraphCard.test.js new file mode 100644 index 00000000..50d2ae14 --- /dev/null +++ b/test/visits/GraphCard.test.js @@ -0,0 +1,70 @@ +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'); + const { legend, scales } = doughnut.prop('options'); + + 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'); + expect(legend).toEqual({ position: 'right' }); + expect(scales).toBeNull(); + }); + + 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'); + 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 }, + }, + ], + }); + }); +}); 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'); + }); +}); 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); + }); +}); diff --git a/test/visits/reducers/shortUrlDetail.test.js b/test/visits/reducers/shortUrlDetail.test.js new file mode 100644 index 00000000..8fac8e71 --- /dev/null +++ b/test/visits/reducers/shortUrlDetail.test.js @@ -0,0 +1,94 @@ +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(ShlinkApiClient.getShortUrl.callCount).toEqual(1); + 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(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); + }); + }); +}); 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"