diff --git a/.travis.yml b/.travis.yml index 0b6895a0..41bb89d5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ install: before_script: - echo "Building commit range ${TRAVIS_COMMIT_RANGE}" - - export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)' | paste -sd ",") + - export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",") script: - npm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c70e71..a576fd69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions. * [#164](https://github.com/shlinkio/shlink-web-client/issues/164) Added max visits control on those URLs which have `maxVisits`. +* [#178](https://github.com/shlinkio/shlink-web-client/issues/178) Short URLs list can now be filtered by date range. #### Changed diff --git a/public/index.html b/public/index.html index e13f0465..0f50c941 100644 --- a/public/index.html +++ b/public/index.html @@ -11,12 +11,12 @@ --> - //FavIcon itself + - //Apple Touch + @@ -44,7 +44,7 @@ - //Normal + @@ -72,7 +72,7 @@ - //MS + diff --git a/src/index.scss b/src/index.scss index 1f4cda05..e3cbf359 100644 --- a/src/index.scss +++ b/src/index.scss @@ -59,3 +59,7 @@ body, .paddingless { padding: 0; } + +.indivisible { + white-space: nowrap; +} diff --git a/src/short-urls/SearchBar.js b/src/short-urls/SearchBar.js index c6537535..71368571 100644 --- a/src/short-urls/SearchBar.js +++ b/src/short-urls/SearchBar.js @@ -1,10 +1,13 @@ import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import React from 'react'; -import { isEmpty } from 'ramda'; +import { isEmpty, pipe } from 'ramda'; import PropTypes from 'prop-types'; +import moment from 'moment'; import SearchField from '../utils/SearchField'; import Tag from '../tags/helpers/Tag'; +import DateRangeRow from '../utils/DateRangeRow'; +import { formatDate } from '../utils/utils'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import './SearchBar.scss'; @@ -13,19 +16,35 @@ const propTypes = { shortUrlsListParams: shortUrlsListParamsType, }; +const dateOrUndefined = (date) => date ? moment(date) : undefined; + const SearchBar = (colorGenerator) => { const SearchBar = ({ listShortUrls, shortUrlsListParams }) => { const selectedTags = shortUrlsListParams.tags || []; + const setDate = (dateName) => pipe( + formatDate(), + (date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date }) + ); return ( -
- listShortUrls({ ...shortUrlsListParams, searchTerm }) - } +
+ listShortUrls({ ...shortUrlsListParams, searchTerm }) + } /> +
+ +
+ {!isEmpty(selectedTags) && ( -

+

  {selectedTags.map((tag) => ( diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 09746f36..ffc9cd21 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -40,12 +40,15 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon ...extraParams, }); }; + handleOrderBy = (orderField, orderDir) => { this.setState({ orderField, orderDir }); this.refreshList({ orderBy: { [orderField]: orderDir } }); }; + orderByColumn = (columnName) => () => this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir)); + renderOrderIcon = (field) => { if (this.state.orderField !== field) { return null; @@ -77,8 +80,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon componentDidMount() { const { match: { params }, location, shortUrlsListParams } = this.props; const query = qs.parse(location.search, { ignoreQueryPrefix: true }); + const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags; - this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags }); + this.refreshList({ page: params.page, tags }); } componentWillUnmount() { diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.js b/src/short-urls/helpers/ShortUrlVisitsCount.js index 32447c8a..663b3a0e 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.js +++ b/src/short-urls/helpers/ShortUrlVisitsCount.js @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; import { shortUrlType } from '../reducers/shortUrlsList'; +import './ShortUrlVisitsCount.scss'; const propTypes = { shortUrl: shortUrlType, @@ -18,9 +19,9 @@ const ShortUrlVisitsCount = ({ shortUrl }) => { return ( - + {visitsCount} - + {' '}/ {maxVisits}{' '} diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.scss b/src/short-urls/helpers/ShortUrlVisitsCount.scss new file mode 100644 index 00000000..b27902fd --- /dev/null +++ b/src/short-urls/helpers/ShortUrlVisitsCount.scss @@ -0,0 +1,3 @@ +.short-urls-visits-count__max-visits-control { + cursor: help; +} diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index 7b53362e..da6e8f86 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -51,7 +51,3 @@ right: calc(100% + 10px); } } - -.short-urls-row__max-visits-control { - cursor: help; -} diff --git a/src/short-urls/reducers/shortUrlsListParams.js b/src/short-urls/reducers/shortUrlsListParams.js index 14962362..6a6aa5e2 100644 --- a/src/short-urls/reducers/shortUrlsListParams.js +++ b/src/short-urls/reducers/shortUrlsListParams.js @@ -8,6 +8,9 @@ export const shortUrlsListParamsType = PropTypes.shape({ page: PropTypes.string, tags: PropTypes.arrayOf(PropTypes.string), searchTerm: PropTypes.string, + startDate: PropTypes.string, + endDate: PropTypes.string, + orderBy: PropTypes.object, }); const initialState = { page: '1' }; diff --git a/src/utils/DateInput.js b/src/utils/DateInput.js index 6d1ba2f2..382ae753 100644 --- a/src/utils/DateInput.js +++ b/src/utils/DateInput.js @@ -4,6 +4,7 @@ import DatePicker from 'react-datepicker'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons'; import * as PropTypes from 'prop-types'; +import classNames from 'classnames'; import './DateInput.scss'; const propTypes = { @@ -21,7 +22,7 @@ const DateInput = (props) => {
( +
+
+ +
+
+ +
+
+); + +DateRangeRow.propTypes = propTypes; + +export default DateRangeRow; diff --git a/src/visits/ShortUrlVisits.scss b/src/utils/DateRangeRow.scss similarity index 72% rename from src/visits/ShortUrlVisits.scss rename to src/utils/DateRangeRow.scss index c75b3b78..bcd94e78 100644 --- a/src/visits/ShortUrlVisits.scss +++ b/src/utils/DateRangeRow.scss @@ -1,6 +1,6 @@ @import '../utils/base'; -.short-url-visits__date-input { +.date-range-row__date-input { @media (max-width: $smMax) { margin-top: .5rem; } diff --git a/src/utils/SearchField.js b/src/utils/SearchField.js index 4cc32acb..a9e4a527 100644 --- a/src/utils/SearchField.js +++ b/src/utils/SearchField.js @@ -2,7 +2,7 @@ import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons'; import PropTypes from 'prop-types'; -import classnames from 'classnames'; +import classNames from 'classnames'; import './SearchField.scss'; const DEFAULT_SEARCH_INTERVAL = 500; @@ -44,7 +44,7 @@ export default class SearchField extends React.Component { const { className, placeholder } = this.props; return ( -
+
- this._performRequest('/short-urls', 'GET', options) - .then((resp) => resp.data.shortUrls); + listShortUrls = pipe( + (options = {}) => reject(isNil, options), + (options = {}) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls) + ); createShortUrl = (options) => { const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options); diff --git a/src/utils/utils.js b/src/utils/utils.js index 304116fe..917873f9 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -68,3 +68,5 @@ export const versionIsValidSemVer = (version) => { return false; } }; + +export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date; diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index bd291590..1473ca63 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -4,14 +4,14 @@ import { isEmpty, mapObjIndexed, values } from 'ramda'; import React from 'react'; import { Card } from 'reactstrap'; import PropTypes from 'prop-types'; -import DateInput from '../utils/DateInput'; +import DateRangeRow from '../utils/DateRangeRow'; import MutedMessage from '../utils/MuttedMessage'; +import { formatDate } from '../utils/utils'; import SortableBarGraph from './SortableBarGraph'; import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import VisitsHeader from './VisitsHeader'; import GraphCard from './GraphCard'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; -import './ShortUrlVisits.scss'; const ShortUrlVisits = ( { processStatsFromVisits }, @@ -32,10 +32,7 @@ const ShortUrlVisits = ( loadVisits = () => { const { match: { params }, getShortUrlVisits } = this.props; const { shortCode } = params; - const dates = mapObjIndexed( - (value) => value && value.format ? value.format('YYYY-MM-DD') : value, - this.state - ); + const dates = mapObjIndexed(formatDate(), this.state); const { startDate, endDate } = dates; // While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs @@ -131,33 +128,19 @@ const ShortUrlVisits = (
); }; + const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits); return (
-
-
- this.setState({ startDate: date }, this.loadVisits)} - /> -
-
- this.setState({ endDate: date }, this.loadVisits)} - /> -
-
+
diff --git a/test/short-urls/SearchBar.test.js b/test/short-urls/SearchBar.test.js index 54bb8781..411e5665 100644 --- a/test/short-urls/SearchBar.test.js +++ b/test/short-urls/SearchBar.test.js @@ -1,8 +1,10 @@ import React from 'react'; import { shallow } from 'enzyme'; +import each from 'jest-each'; import searchBarCreator from '../../src/short-urls/SearchBar'; import SearchField from '../../src/utils/SearchField'; import Tag from '../../src/tags/helpers/Tag'; +import DateRangeRow from '../../src/utils/DateRangeRow'; describe('', () => { let wrapper; @@ -20,6 +22,12 @@ describe('', () => { expect(wrapper.find(SearchField)).toHaveLength(1); }); + it('renders a DateRangeRow', () => { + wrapper = shallow(); + + expect(wrapper.find(DateRangeRow)).toHaveLength(1); + }); + it('renders no tags when the list of tags is empty', () => { wrapper = shallow(); @@ -53,4 +61,13 @@ describe('', () => { tag.simulate('close'); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); + + each([ 'startDateChange', 'endDateChange' ]).it('updates short URLs list when date range changes', (event) => { + wrapper = shallow(); + const dateRange = wrapper.find(DateRangeRow); + + expect(listShortUrlsMock).not.toHaveBeenCalled(); + dateRange.simulate(event); + expect(listShortUrlsMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/short-urls/helpers/ShortUrlVisitsCount.test.js b/test/short-urls/helpers/ShortUrlVisitsCount.test.js index 2700b38c..9949fa4c 100644 --- a/test/short-urls/helpers/ShortUrlVisitsCount.test.js +++ b/test/short-urls/helpers/ShortUrlVisitsCount.test.js @@ -17,7 +17,7 @@ describe('', () => { it('just returns visits when no maxVisits is provided', () => { const visitsCount = 45; const wrapper = createWrapper({ visitsCount }); - const maxVisitsHelper = wrapper.find('.short-urls-row__max-visits-control'); + const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); expect(wrapper.html()).toEqual(`${visitsCount}`); @@ -30,7 +30,7 @@ describe('', () => { const maxVisits = 500; const meta = { maxVisits }; const wrapper = createWrapper({ visitsCount, meta }); - const maxVisitsHelper = wrapper.find('.short-urls-row__max-visits-control'); + const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); expect(wrapper.html()).toContain(`/ ${maxVisits}`); diff --git a/test/utils/DateRangeRow.test.js b/test/utils/DateRangeRow.test.js new file mode 100644 index 00000000..5b6a4cc5 --- /dev/null +++ b/test/utils/DateRangeRow.test.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DateRangeRow from '../../src/utils/DateRangeRow'; +import DateInput from '../../src/utils/DateInput'; + +describe('', () => { + let wrapper; + const onEndDateChange = jest.fn(); + const onStartDateChange = jest.fn(); + + beforeEach(() => { + wrapper = shallow(); + }); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + it('renders two date inputs', () => { + const dateInput = wrapper.find(DateInput); + + expect(dateInput).toHaveLength(2); + }); + + it('invokes start date callback when change event is triggered on first input', () => { + const dateInput = wrapper.find(DateInput).first(); + + expect(onStartDateChange).not.toHaveBeenCalled(); + dateInput.simulate('change'); + expect(onStartDateChange).toHaveBeenCalled(); + }); + + it('invokes end date callback when change event is triggered on second input', () => { + const dateInput = wrapper.find(DateInput).last(); + + expect(onEndDateChange).not.toHaveBeenCalled(); + dateInput.simulate('change'); + expect(onEndDateChange).toHaveBeenCalled(); + }); +}); diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index e7006c75..40d634c5 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -5,8 +5,8 @@ import { Card } from 'reactstrap'; import createShortUrlVisits from '../../src/visits/ShortUrlVisits'; import MutedMessage from '../../src/utils/MuttedMessage'; import GraphCard from '../../src/visits/GraphCard'; -import DateInput from '../../src/utils/DateInput'; import SortableBarGraph from '../../src/visits/SortableBarGraph'; +import DateRangeRow from '../../src/utils/DateRangeRow'; describe('', () => { let wrapper; @@ -82,14 +82,15 @@ describe('', () => { it('reloads visits when selected dates change', () => { const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); - const dateInput = wrapper.find(DateInput).first(); + const dateRange = wrapper.find(DateRangeRow); - 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'); + dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00'); + dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00'); + dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00'); expect(getShortUrlVisitsMock).toHaveBeenCalledTimes(4); - expect(wrapper.state('startDate')).toEqual('2016-01-03T00:00:00+01:00'); + expect(wrapper.state('startDate')).toEqual('2016-01-01T00:00:00+01:00'); + expect(wrapper.state('endDate')).toEqual('2016-01-03T00:00:00+01:00'); }); it('holds the map button content generator on cities graph extraHeaderContent', () => {