Added filtering by date to short URLs list

This commit is contained in:
Alejandro Celaya 2020-01-14 19:59:25 +01:00
parent 124441238b
commit b60908a5e9
12 changed files with 99 additions and 50 deletions

View file

@ -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">

View file

@ -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)}
onStartDateChane={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" />
&nbsp; &nbsp;
{selectedTags.map((tag) => ( {selectedTags.map((tag) => (

View file

@ -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() {

View file

@ -51,7 +51,3 @@
right: calc(100% + 10px); right: calc(100% + 10px);
} }
} }
.short-urls-row__max-visits-control {
cursor: help;
}

View file

@ -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' };

View file

@ -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
View 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,
onStartDateChane: PropTypes.func.isRequired,
onEndDateChange: PropTypes.func.isRequired,
};
const DateRangeRow = ({ startDate, endDate, onStartDateChane, 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={onStartDateChane}
/>
</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;

View file

@ -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;
} }

View file

@ -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"

View file

@ -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);

View file

@ -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;

View file

@ -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} onStartDateChane={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>