mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #187 from acelaya-forks/feature/date-filter
Feature/date filter
This commit is contained in:
commit
7826000384
21 changed files with 177 additions and 61 deletions
|
@ -15,7 +15,7 @@ install:
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
- 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:
|
script:
|
||||||
- npm run lint
|
- npm run lint
|
||||||
|
|
|
@ -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.
|
* [#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`.
|
* [#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
|
#### Changed
|
||||||
|
|
||||||
|
|
|
@ -11,12 +11,12 @@
|
||||||
-->
|
-->
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||||
|
|
||||||
//FavIcon itself
|
<!-- FavIcon itself -->
|
||||||
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
||||||
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon.svg" sizes="any">
|
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon.svg" sizes="any">
|
||||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.png">
|
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.png">
|
||||||
<link rel="icon" type="image/gif" href="%PUBLIC_URL%/favicon.gif">
|
<link rel="icon" type="image/gif" href="%PUBLIC_URL%/favicon.gif">
|
||||||
//Apple Touch
|
<!-- Apple Touch -->
|
||||||
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
||||||
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
||||||
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
<link rel="apple-touch-icon" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
<link rel="apple-touch-icon" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
<link rel="apple-touch-icon" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
||||||
<link rel="apple-touch-icon" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
<link rel="apple-touch-icon" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
||||||
//Normal
|
<!-- Normal -->
|
||||||
<link rel="icon" type="image/png" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
<link rel="icon" type="image/png" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
||||||
<link rel="icon" type="image/png" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
<link rel="icon" type="image/png" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
<link rel="icon" type="image/png" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
||||||
//MS
|
<!-- MS -->
|
||||||
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||||
<meta name="msapplication-square70x70logo" content="%PUBLIC_URL%/icons/icon-70x70.png">
|
<meta name="msapplication-square70x70logo" content="%PUBLIC_URL%/icons/icon-70x70.png">
|
||||||
<meta name="msapplication-square144x144logo" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
<meta name="msapplication-square144x144logo" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||||
|
|
|
@ -59,3 +59,7 @@ body,
|
||||||
.paddingless {
|
.paddingless {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indivisible {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { isEmpty } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import moment from 'moment';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
import Tag from '../tags/helpers/Tag';
|
||||||
|
import DateRangeRow from '../utils/DateRangeRow';
|
||||||
|
import { formatDate } from '../utils/utils';
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||||
import './SearchBar.scss';
|
import './SearchBar.scss';
|
||||||
|
|
||||||
|
@ -13,19 +16,35 @@ const propTypes = {
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
shortUrlsListParams: shortUrlsListParamsType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dateOrUndefined = (date) => date ? moment(date) : undefined;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator) => {
|
const SearchBar = (colorGenerator) => {
|
||||||
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
||||||
const selectedTags = shortUrlsListParams.tags || [];
|
const selectedTags = shortUrlsListParams.tags || [];
|
||||||
|
const setDate = (dateName) => pipe(
|
||||||
|
formatDate(),
|
||||||
|
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date })
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="serach-bar-container">
|
<div className="search-bar-container">
|
||||||
<SearchField onChange={
|
<SearchField
|
||||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
onChange={
|
||||||
}
|
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<DateRangeRow
|
||||||
|
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||||
|
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||||
|
onStartDateChange={setDate('startDate')}
|
||||||
|
onEndDateChange={setDate('endDate')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
{!isEmpty(selectedTags) && (
|
||||||
<h4 className="search-bar__selected-tag mt-2">
|
<h4 className="search-bar__selected-tag mt-3">
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||||
|
|
||||||
{selectedTags.map((tag) => (
|
{selectedTags.map((tag) => (
|
||||||
|
|
|
@ -40,12 +40,15 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||||
...extraParams,
|
...extraParams,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handleOrderBy = (orderField, orderDir) => {
|
handleOrderBy = (orderField, orderDir) => {
|
||||||
this.setState({ orderField, orderDir });
|
this.setState({ orderField, orderDir });
|
||||||
this.refreshList({ orderBy: { [orderField]: orderDir } });
|
this.refreshList({ orderBy: { [orderField]: orderDir } });
|
||||||
};
|
};
|
||||||
|
|
||||||
orderByColumn = (columnName) => () =>
|
orderByColumn = (columnName) => () =>
|
||||||
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
|
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
|
||||||
|
|
||||||
renderOrderIcon = (field) => {
|
renderOrderIcon = (field) => {
|
||||||
if (this.state.orderField !== field) {
|
if (this.state.orderField !== field) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -77,8 +80,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { match: { params }, location, shortUrlsListParams } = this.props;
|
const { match: { params }, location, shortUrlsListParams } = this.props;
|
||||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
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() {
|
componentWillUnmount() {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
|
import './ShortUrlVisitsCount.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
shortUrl: shortUrlType,
|
shortUrl: shortUrlType,
|
||||||
|
@ -18,9 +19,9 @@ const ShortUrlVisitsCount = ({ shortUrl }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<span>
|
<span className="indivisible">
|
||||||
{visitsCount}
|
{visitsCount}
|
||||||
<small id="maxVisitsControl" className="short-urls-row__max-visits-control">
|
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
|
||||||
{' '}/ {maxVisits}{' '}
|
{' '}/ {maxVisits}{' '}
|
||||||
<sup>
|
<sup>
|
||||||
<FontAwesomeIcon icon={infoIcon} />
|
<FontAwesomeIcon icon={infoIcon} />
|
||||||
|
|
3
src/short-urls/helpers/ShortUrlVisitsCount.scss
Normal file
3
src/short-urls/helpers/ShortUrlVisitsCount.scss
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.short-urls-visits-count__max-visits-control {
|
||||||
|
cursor: help;
|
||||||
|
}
|
|
@ -51,7 +51,3 @@
|
||||||
right: calc(100% + 10px);
|
right: calc(100% + 10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-row__max-visits-control {
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,6 +8,9 @@ export const shortUrlsListParamsType = PropTypes.shape({
|
||||||
page: PropTypes.string,
|
page: PropTypes.string,
|
||||||
tags: PropTypes.arrayOf(PropTypes.string),
|
tags: PropTypes.arrayOf(PropTypes.string),
|
||||||
searchTerm: PropTypes.string,
|
searchTerm: PropTypes.string,
|
||||||
|
startDate: PropTypes.string,
|
||||||
|
endDate: PropTypes.string,
|
||||||
|
orderBy: PropTypes.object,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialState = { page: '1' };
|
const initialState = { page: '1' };
|
||||||
|
|
|
@ -4,6 +4,7 @@ import DatePicker from 'react-datepicker';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import * as PropTypes from 'prop-types';
|
import * as PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
import './DateInput.scss';
|
import './DateInput.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
@ -21,7 +22,7 @@ const DateInput = (props) => {
|
||||||
<div className="date-input-container">
|
<div className="date-input-container">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
{...props}
|
{...props}
|
||||||
className={`date-input-container__input form-control ${className || ''}`}
|
className={classNames('date-input-container__input form-control', className)}
|
||||||
dateFormat="YYYY-MM-DD"
|
dateFormat="YYYY-MM-DD"
|
||||||
readOnly
|
readOnly
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
40
src/utils/DateRangeRow.js
Normal file
40
src/utils/DateRangeRow.js
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import DateInput from './DateInput';
|
||||||
|
import './DateRangeRow.scss';
|
||||||
|
|
||||||
|
const dateType = PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]);
|
||||||
|
const propTypes = {
|
||||||
|
startDate: dateType,
|
||||||
|
endDate: dateType,
|
||||||
|
onStartDateChange: PropTypes.func.isRequired,
|
||||||
|
onEndDateChange: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
||||||
|
<DateInput
|
||||||
|
selected={startDate}
|
||||||
|
placeholderText="Since"
|
||||||
|
isClearable
|
||||||
|
maxDate={endDate}
|
||||||
|
onChange={onStartDateChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||||
|
<DateInput
|
||||||
|
className="date-range-row__date-input"
|
||||||
|
selected={endDate}
|
||||||
|
placeholderText="Until"
|
||||||
|
isClearable
|
||||||
|
minDate={startDate}
|
||||||
|
onChange={onEndDateChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
DateRangeRow.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default DateRangeRow;
|
|
@ -1,6 +1,6 @@
|
||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.short-url-visits__date-input {
|
.date-range-row__date-input {
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
}
|
}
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import './SearchField.scss';
|
import './SearchField.scss';
|
||||||
|
|
||||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||||
|
@ -44,7 +44,7 @@ export default class SearchField extends React.Component {
|
||||||
const { className, placeholder } = this.props;
|
const { className, placeholder } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames('search-field', className)}>
|
<div className={classNames('search-field', className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control form-control-lg search-field__input"
|
className="form-control form-control-lg search-field__input"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
import { isEmpty, isNil, pipe, reject } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
export const apiErrorType = PropTypes.shape({
|
export const apiErrorType = PropTypes.shape({
|
||||||
|
@ -21,9 +21,10 @@ export default class ShlinkApiClient {
|
||||||
this._apiKey = apiKey || '';
|
this._apiKey = apiKey || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
listShortUrls = (options = {}) =>
|
listShortUrls = pipe(
|
||||||
this._performRequest('/short-urls', 'GET', options)
|
(options = {}) => reject(isNil, options),
|
||||||
.then((resp) => resp.data.shortUrls);
|
(options = {}) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls)
|
||||||
|
);
|
||||||
|
|
||||||
createShortUrl = (options) => {
|
createShortUrl = (options) => {
|
||||||
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
|
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
|
||||||
|
|
|
@ -68,3 +68,5 @@ export const versionIsValidSemVer = (version) => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
|
||||||
|
|
|
@ -4,14 +4,14 @@ import { isEmpty, mapObjIndexed, values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import DateInput from '../utils/DateInput';
|
import DateRangeRow from '../utils/DateRangeRow';
|
||||||
import MutedMessage from '../utils/MuttedMessage';
|
import MutedMessage from '../utils/MuttedMessage';
|
||||||
|
import { formatDate } from '../utils/utils';
|
||||||
import SortableBarGraph from './SortableBarGraph';
|
import SortableBarGraph from './SortableBarGraph';
|
||||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||||
import VisitsHeader from './VisitsHeader';
|
import VisitsHeader from './VisitsHeader';
|
||||||
import GraphCard from './GraphCard';
|
import GraphCard from './GraphCard';
|
||||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||||
import './ShortUrlVisits.scss';
|
|
||||||
|
|
||||||
const ShortUrlVisits = (
|
const ShortUrlVisits = (
|
||||||
{ processStatsFromVisits },
|
{ processStatsFromVisits },
|
||||||
|
@ -32,10 +32,7 @@ const ShortUrlVisits = (
|
||||||
loadVisits = () => {
|
loadVisits = () => {
|
||||||
const { match: { params }, getShortUrlVisits } = this.props;
|
const { match: { params }, getShortUrlVisits } = this.props;
|
||||||
const { shortCode } = params;
|
const { shortCode } = params;
|
||||||
const dates = mapObjIndexed(
|
const dates = mapObjIndexed(formatDate(), this.state);
|
||||||
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
|
|
||||||
this.state
|
|
||||||
);
|
|
||||||
const { startDate, endDate } = dates;
|
const { startDate, endDate } = dates;
|
||||||
|
|
||||||
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
|
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
|
||||||
|
@ -131,33 +128,19 @@ const ShortUrlVisits = (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shlink-container">
|
<div className="shlink-container">
|
||||||
<VisitsHeader shortUrlDetail={shortUrlDetail} />
|
<VisitsHeader shortUrlDetail={shortUrlDetail} />
|
||||||
|
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<div className="row">
|
<DateRangeRow
|
||||||
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
startDate={this.state.startDate}
|
||||||
<DateInput
|
endDate={this.state.endDate}
|
||||||
selected={this.state.startDate}
|
onStartDateChange={setDate('startDate')}
|
||||||
placeholderText="Since"
|
onEndDateChange={setDate('endDate')}
|
||||||
isClearable
|
/>
|
||||||
maxDate={this.state.endDate}
|
|
||||||
onChange={(date) => this.setState({ startDate: date }, this.loadVisits)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
|
||||||
<DateInput
|
|
||||||
className="short-url-visits__date-input"
|
|
||||||
selected={this.state.endDate}
|
|
||||||
placeholderText="Until"
|
|
||||||
isClearable
|
|
||||||
minDate={this.state.startDate}
|
|
||||||
onChange={(date) => this.setState({ endDate: date }, this.loadVisits)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
|
import each from 'jest-each';
|
||||||
import searchBarCreator from '../../src/short-urls/SearchBar';
|
import searchBarCreator from '../../src/short-urls/SearchBar';
|
||||||
import SearchField from '../../src/utils/SearchField';
|
import SearchField from '../../src/utils/SearchField';
|
||||||
import Tag from '../../src/tags/helpers/Tag';
|
import Tag from '../../src/tags/helpers/Tag';
|
||||||
|
import DateRangeRow from '../../src/utils/DateRangeRow';
|
||||||
|
|
||||||
describe('<SearchBar />', () => {
|
describe('<SearchBar />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
@ -20,6 +22,12 @@ describe('<SearchBar />', () => {
|
||||||
expect(wrapper.find(SearchField)).toHaveLength(1);
|
expect(wrapper.find(SearchField)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a DateRangeRow', () => {
|
||||||
|
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
|
||||||
|
|
||||||
|
expect(wrapper.find(DateRangeRow)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders no tags when the list of tags is empty', () => {
|
it('renders no tags when the list of tags is empty', () => {
|
||||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
|
wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
|
||||||
|
|
||||||
|
@ -53,4 +61,13 @@ describe('<SearchBar />', () => {
|
||||||
tag.simulate('close');
|
tag.simulate('close');
|
||||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
each([ 'startDateChange', 'endDateChange' ]).it('updates short URLs list when date range changes', (event) => {
|
||||||
|
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
|
||||||
|
const dateRange = wrapper.find(DateRangeRow);
|
||||||
|
|
||||||
|
expect(listShortUrlsMock).not.toHaveBeenCalled();
|
||||||
|
dateRange.simulate(event);
|
||||||
|
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ describe('<ShortUrlVisitsCount />', () => {
|
||||||
it('just returns visits when no maxVisits is provided', () => {
|
it('just returns visits when no maxVisits is provided', () => {
|
||||||
const visitsCount = 45;
|
const visitsCount = 45;
|
||||||
const wrapper = createWrapper({ visitsCount });
|
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);
|
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
|
||||||
expect(wrapper.html()).toEqual(`<span>${visitsCount}</span>`);
|
expect(wrapper.html()).toEqual(`<span>${visitsCount}</span>`);
|
||||||
|
@ -30,7 +30,7 @@ describe('<ShortUrlVisitsCount />', () => {
|
||||||
const maxVisits = 500;
|
const maxVisits = 500;
|
||||||
const meta = { maxVisits };
|
const meta = { maxVisits };
|
||||||
const wrapper = createWrapper({ visitsCount, meta });
|
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);
|
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
|
||||||
expect(wrapper.html()).toContain(`/ ${maxVisits}`);
|
expect(wrapper.html()).toContain(`/ ${maxVisits}`);
|
||||||
|
|
40
test/utils/DateRangeRow.test.js
Normal file
40
test/utils/DateRangeRow.test.js
Normal file
|
@ -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('<DateRangeRow />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const onEndDateChange = jest.fn();
|
||||||
|
const onStartDateChange = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(<DateRangeRow onEndDateChange={onEndDateChange} onStartDateChange={onStartDateChange} />);
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -5,8 +5,8 @@ import { Card } from 'reactstrap';
|
||||||
import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
|
import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
|
||||||
import MutedMessage from '../../src/utils/MuttedMessage';
|
import MutedMessage from '../../src/utils/MuttedMessage';
|
||||||
import GraphCard from '../../src/visits/GraphCard';
|
import GraphCard from '../../src/visits/GraphCard';
|
||||||
import DateInput from '../../src/utils/DateInput';
|
|
||||||
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
||||||
|
import DateRangeRow from '../../src/utils/DateRangeRow';
|
||||||
|
|
||||||
describe('<ShortUrlVisits />', () => {
|
describe('<ShortUrlVisits />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
@ -82,14 +82,15 @@ describe('<ShortUrlVisits />', () => {
|
||||||
|
|
||||||
it('reloads visits when selected dates change', () => {
|
it('reloads visits when selected dates change', () => {
|
||||||
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
|
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');
|
dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00');
|
||||||
dateInput.simulate('change', '2016-01-02T00:00:00+01:00');
|
dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00');
|
||||||
dateInput.simulate('change', '2016-01-03T00:00:00+01:00');
|
dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00');
|
||||||
|
|
||||||
expect(getShortUrlVisitsMock).toHaveBeenCalledTimes(4);
|
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', () => {
|
it('holds the map button content generator on cities graph extraHeaderContent', () => {
|
||||||
|
|
Loading…
Reference in a new issue