mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-05-07 20:02:58 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e26cdc11c3 | ||
|
|
fa54aa3128 | ||
|
|
e31e70039d | ||
|
|
cb761dea8f | ||
|
|
949e0da105 | ||
|
|
770cc59448 | ||
|
|
72dd2bd0a7 | ||
|
|
54733eaa18 | ||
|
|
52c56f7918 | ||
|
|
c46d5187c1 | ||
|
|
05e3e87653 | ||
|
|
8b9289ff08 | ||
|
|
16ffbcfbc0 | ||
|
|
d825b6e174 | ||
|
|
73e55cc742 | ||
|
|
32cc1cc580 | ||
|
|
e00574553f |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## 2.5.1 - 2020-06-06
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#254](https://github.com/shlinkio/shlink-web-client/issues/254) Reduced duplication on code to handle mercure topics binding.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#276](https://github.com/shlinkio/shlink-web-client/issues/276) Fixed default grouping used for visits line chart, making it be dynamic depending on how old the short URL is.
|
||||
* [#280](https://github.com/shlinkio/shlink-web-client/issues/280) Fixed shlink-web-client version not being properly passed when building stable tags of the docker image.
|
||||
* [#269](https://github.com/shlinkio/shlink-web-client/issues/269) Fixed doughnut chart legends getting to big and hiding charts on mobile devices.
|
||||
|
||||
|
||||
## 2.5.0 - 2020-05-31
|
||||
|
||||
#### Added
|
||||
|
||||
@@ -27,7 +27,7 @@ else
|
||||
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||
|
||||
docker buildx build --push \
|
||||
--build-arg SHLINK_VERSION=${TRAVIS_TAG#?} \
|
||||
--build-arg VERSION=${TRAVIS_TAG#?} \
|
||||
--platform ${PLATFORMS} \
|
||||
${TAGS} .
|
||||
fi
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||
|
||||
export const bindToMercureTopic = (mercureInfo, realTimeUpdates, topic, onMessage, onTokenExpired) => () => {
|
||||
const { enabled } = realTimeUpdates;
|
||||
export const bindToMercureTopic = (mercureInfo, topic, onMessage, onTokenExpired) => () => {
|
||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||
|
||||
if (!enabled || loading || error) {
|
||||
if (loading || error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -22,3 +22,7 @@ export const bindToMercureTopic = (mercureInfo, realTimeUpdates, topic, onMessag
|
||||
|
||||
return () => es.close();
|
||||
};
|
||||
|
||||
export const useMercureTopicBinding = (mercureInfo, topic, onMessage, onTokenExpired) => {
|
||||
useEffect(bindToMercureTopic(mercureInfo, topic, onMessage, onTokenExpired), [ mercureInfo ]);
|
||||
};
|
||||
|
||||
@@ -29,8 +29,16 @@ export default handleActions({
|
||||
|
||||
export const loadMercureInfo = (buildShlinkApiClient) => () => async (dispatch, getState) => {
|
||||
dispatch({ type: GET_MERCURE_INFO_START });
|
||||
|
||||
const { settings } = getState();
|
||||
const { mercureInfo } = buildShlinkApiClient(getState);
|
||||
|
||||
if (!settings.realTimeUpdates.enabled) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mercureInfo();
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import { serverType } from '../servers/prop-types';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { determineOrderDir } from '../utils/utils';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { useMercureTopicBinding } from '../mercure/helpers';
|
||||
import { shortUrlType } from './reducers/shortUrlsList';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './ShortUrlsList.scss';
|
||||
@@ -34,7 +33,6 @@ const propTypes = {
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
// FIXME Replace with typescript: (ShortUrlsRow component)
|
||||
@@ -52,7 +50,6 @@ const ShortUrlsList = (ShortUrlsRow) => {
|
||||
createNewVisit,
|
||||
loadMercureInfo,
|
||||
mercureInfo,
|
||||
settings: { realTimeUpdates },
|
||||
}) => {
|
||||
const { orderBy } = shortUrlsListParams;
|
||||
const [ order, setOrder ] = useState({
|
||||
@@ -119,10 +116,7 @@ const ShortUrlsList = (ShortUrlsRow) => {
|
||||
|
||||
return resetShortUrlParams;
|
||||
}, []);
|
||||
useEffect(
|
||||
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||
[ mercureInfo ]
|
||||
);
|
||||
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
||||
@@ -31,7 +31,7 @@ const provideServices = (bottle, connect) => {
|
||||
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||
bottle.decorator('ShortUrlsList', connect(
|
||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'settings' ],
|
||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { useMercureTopicBinding } from '../mercure/helpers';
|
||||
import { TagsListType } from './reducers/tagsList';
|
||||
|
||||
const { ceil } = Math;
|
||||
@@ -20,23 +19,18 @@ const propTypes = {
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
const TagsList = (TagCard) => {
|
||||
const TagListComp = (
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo, settings }
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo }
|
||||
) => {
|
||||
const { realTimeUpdates } = settings;
|
||||
const [ displayedTag, setDisplayedTag ] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
}, []);
|
||||
useEffect(
|
||||
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||
[ mercureInfo ]
|
||||
);
|
||||
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.loading) {
|
||||
|
||||
@@ -29,7 +29,7 @@ const provideServices = (bottle, connect) => {
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
||||
bottle.decorator('TagsList', connect(
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
|
||||
[ 'forceListTags', 'filterTags', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import qs from 'qs';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { useMercureTopicBinding } from '../mercure/helpers';
|
||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
|
||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
@@ -26,7 +25,6 @@ const propTypes = {
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
const ShortUrlVisits = (VisitsStats) => {
|
||||
@@ -42,7 +40,6 @@ const ShortUrlVisits = (VisitsStats) => {
|
||||
createNewVisit,
|
||||
loadMercureInfo,
|
||||
mercureInfo,
|
||||
settings: { realTimeUpdates },
|
||||
}) => {
|
||||
const { params } = match;
|
||||
const { shortCode } = params;
|
||||
@@ -54,16 +51,7 @@ const ShortUrlVisits = (VisitsStats) => {
|
||||
useEffect(() => {
|
||||
getShortUrlDetail(shortCode, domain);
|
||||
}, []);
|
||||
useEffect(
|
||||
bindToMercureTopic(
|
||||
mercureInfo,
|
||||
realTimeUpdates,
|
||||
`https://shlink.io/new-visit/${shortCode}`,
|
||||
createNewVisit,
|
||||
loadMercureInfo
|
||||
),
|
||||
[ mercureInfo ],
|
||||
);
|
||||
useMercureTopicBinding(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit, loadMercureInfo);
|
||||
|
||||
return (
|
||||
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||
import { SettingsType } from '../settings/reducers/settings';
|
||||
import { bindToMercureTopic } from '../mercure/helpers';
|
||||
import { useMercureTopicBinding } from '../mercure/helpers';
|
||||
import { TagVisitsType } from './reducers/tagVisits';
|
||||
import TagVisitsHeader from './TagVisitsHeader';
|
||||
|
||||
@@ -19,7 +18,6 @@ const propTypes = {
|
||||
createNewVisit: PropTypes.func,
|
||||
loadMercureInfo: PropTypes.func,
|
||||
mercureInfo: MercureInfoType,
|
||||
settings: SettingsType,
|
||||
};
|
||||
|
||||
const TagVisits = (VisitsStats, colorGenerator) => {
|
||||
@@ -32,22 +30,12 @@ const TagVisits = (VisitsStats, colorGenerator) => {
|
||||
createNewVisit,
|
||||
loadMercureInfo,
|
||||
mercureInfo,
|
||||
settings: { realTimeUpdates },
|
||||
}) => {
|
||||
const { params } = match;
|
||||
const { tag } = params;
|
||||
const loadVisits = (dates) => getTagVisits(tag, dates);
|
||||
|
||||
useEffect(
|
||||
bindToMercureTopic(
|
||||
mercureInfo,
|
||||
realTimeUpdates,
|
||||
'https://shlink.io/new-visit',
|
||||
createNewVisit,
|
||||
loadMercureInfo
|
||||
),
|
||||
[ mercureInfo ],
|
||||
);
|
||||
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
|
||||
|
||||
return (
|
||||
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits}>
|
||||
|
||||
@@ -9,8 +9,8 @@ import DateRangeRow from '../utils/DateRangeRow';
|
||||
import Message from '../utils/Message';
|
||||
import { formatDate } from '../utils/helpers/date';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import SortableBarGraph from './SortableBarGraph';
|
||||
import GraphCard from './GraphCard';
|
||||
import SortableBarGraph from './helpers/SortableBarGraph';
|
||||
import GraphCard from './helpers/GraphCard';
|
||||
import LineChartCard from './helpers/LineChartCard';
|
||||
import VisitsTable from './VisitsTable';
|
||||
import { VisitsInfoType } from './types';
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||
import { keys, values } from 'ramda';
|
||||
import { fillTheGaps } from '../utils/helpers/visits';
|
||||
import './GraphCard.scss';
|
||||
import classNames from 'classnames';
|
||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||
import './DefaultChart.scss';
|
||||
|
||||
const propTypes = {
|
||||
title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]),
|
||||
footer: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
|
||||
isBarChart: PropTypes.bool,
|
||||
stats: PropTypes.object,
|
||||
max: PropTypes.number,
|
||||
@@ -54,14 +53,47 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData, hig
|
||||
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
|
||||
|
||||
const determineHeight = (isBarChart, labels) => {
|
||||
if (!isBarChart && labels.length > 8) {
|
||||
return 200;
|
||||
if (!isBarChart) {
|
||||
return 300;
|
||||
}
|
||||
|
||||
return isBarChart && labels.length > 20 ? labels.length * 8 : null;
|
||||
};
|
||||
|
||||
const renderGraph = (title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick) => {
|
||||
/* eslint-disable react/prop-types */
|
||||
const renderPieChartLegend = ({ config }) => {
|
||||
const { labels, datasets } = config.data;
|
||||
const { defaultColor } = config.options;
|
||||
const [{ backgroundColor: colors }] = datasets;
|
||||
|
||||
return (
|
||||
<ul className="default-chart__pie-chart-legend">
|
||||
{labels.map((label, index) => (
|
||||
<li key={label} className="default-chart__pie-chart-legend-item d-flex">
|
||||
<div
|
||||
className="default-chart__pie-chart-legend-item-color"
|
||||
style={{ backgroundColor: colors[index] || defaultColor }}
|
||||
/>
|
||||
<small className="default-chart__pie-chart-legend-item-text flex-fill">{label}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
/* eslint-enable react/prop-types */
|
||||
|
||||
const chartElementAtEvent = (onClick) => ([ chart ]) => {
|
||||
if (!onClick || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _index, _chart: { data } } = chart;
|
||||
const { labels } = data;
|
||||
|
||||
onClick(labels[_index]);
|
||||
};
|
||||
|
||||
const DefaultChart = ({ title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick }) => {
|
||||
const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0;
|
||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||
const labels = keys(stats).map(dropLabelIfHidden);
|
||||
@@ -73,9 +105,11 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, highlighte
|
||||
return acc;
|
||||
}, { ...stats }));
|
||||
const highlightedData = hasHighlightedStats && fillTheGaps(highlightedStats, labels);
|
||||
const chartRef = useRef();
|
||||
|
||||
const options = {
|
||||
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||
legend: { display: false },
|
||||
legendCallback: !isBarChart && renderPieChartLegend,
|
||||
scales: isBarChart && {
|
||||
xAxes: [
|
||||
{
|
||||
@@ -100,33 +134,26 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, highlighte
|
||||
|
||||
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
|
||||
return (
|
||||
<Component
|
||||
key={height}
|
||||
data={graphData}
|
||||
options={options}
|
||||
height={height}
|
||||
getElementAtEvent={([ chart ]) => {
|
||||
if (!onClick || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _index, _chart: { data } } = chart;
|
||||
const { labels } = data;
|
||||
|
||||
onClick(labels[_index]);
|
||||
}}
|
||||
/>
|
||||
<div className="row">
|
||||
<div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}>
|
||||
<Component
|
||||
ref={chartRef}
|
||||
key={height}
|
||||
data={graphData}
|
||||
options={options}
|
||||
height={height}
|
||||
getElementAtEvent={chartElementAtEvent(onClick)}
|
||||
/>
|
||||
</div>
|
||||
{!isBarChart && (
|
||||
<div className="col-sm-12 col-md-5">
|
||||
{chartRef.current && chartRef.current.chartInstance.generateLegend()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick }) => (
|
||||
<Card>
|
||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick)}</CardBody>
|
||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
DefaultChart.propTypes = propTypes;
|
||||
|
||||
GraphCard.propTypes = propTypes;
|
||||
|
||||
export default GraphCard;
|
||||
export default DefaultChart;
|
||||
29
src/visits/helpers/DefaultChart.scss
Normal file
29
src/visits/helpers/DefaultChart.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.default-chart__pie-chart-legend {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.default-chart__pie-chart-legend-item:not(:first-child) {
|
||||
margin-top: .3rem;
|
||||
}
|
||||
|
||||
.default-chart__pie-chart-legend-item-color {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.default-chart__pie-chart-legend-item-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
30
src/visits/helpers/GraphCard.js
Normal file
30
src/visits/helpers/GraphCard.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import DefaultChart from './DefaultChart';
|
||||
import './GraphCard.scss';
|
||||
|
||||
const propTypes = {
|
||||
title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]),
|
||||
footer: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
|
||||
isBarChart: PropTypes.bool,
|
||||
stats: PropTypes.object,
|
||||
max: PropTypes.number,
|
||||
highlightedStats: PropTypes.object,
|
||||
highlightedLabel: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
const GraphCard = ({ title, footer, ...rest }) => (
|
||||
<Card>
|
||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>
|
||||
<DefaultChart title={title} {...rest} />
|
||||
</CardBody>
|
||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
GraphCard.propTypes = propTypes;
|
||||
|
||||
export default GraphCard;
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DropdownItem,
|
||||
} from 'reactstrap';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { reverse } from 'ramda';
|
||||
import { always, cond, reverse } from 'ramda';
|
||||
import moment from 'moment';
|
||||
import { VisitType } from '../types';
|
||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||
@@ -52,6 +52,18 @@ const STEP_TO_DATE_FORMAT = {
|
||||
monthly: (date) => moment(date).format('YYYY-MM'),
|
||||
};
|
||||
|
||||
const determineInitialStep = (oldestVisitDate) => {
|
||||
const now = moment();
|
||||
const oldestDate = moment(oldestVisitDate);
|
||||
const matcher = cond([
|
||||
[ () => now.diff(oldestDate, 'day') <= 2, always('hourly') ], // Less than 2 days
|
||||
[ () => now.diff(oldestDate, 'month') <= 1, always('daily') ], // Between 2 days and 1 month
|
||||
[ () => now.diff(oldestDate, 'month') <= 6, always('weekly') ], // Between 1 and 6 months
|
||||
]);
|
||||
|
||||
return matcher() || 'monthly';
|
||||
};
|
||||
|
||||
const groupVisitsByStep = (step, visits) => visits.reduce((acc, visit) => {
|
||||
const key = STEP_TO_DATE_FORMAT[step](visit.date);
|
||||
|
||||
@@ -93,7 +105,9 @@ const generateDataset = (stats, label, color) => ({
|
||||
});
|
||||
|
||||
const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'Selected' }) => {
|
||||
const [ step, setStep ] = useState('monthly');
|
||||
const [ step, setStep ] = useState(
|
||||
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly'
|
||||
);
|
||||
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
|
||||
|
||||
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import PaginationDropdown from '../utils/PaginationDropdown';
|
||||
import { rangeOf } from '../utils/utils';
|
||||
import { roundTen } from '../utils/helpers/numbers';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import SortingDropdown from '../../utils/SortingDropdown';
|
||||
import PaginationDropdown from '../../utils/PaginationDropdown';
|
||||
import { rangeOf } from '../../utils/utils';
|
||||
import { roundTen } from '../../utils/helpers/numbers';
|
||||
import SimplePaginator from '../../common/SimplePaginator';
|
||||
import GraphCard from './GraphCard';
|
||||
|
||||
const propTypes = {
|
||||
@@ -16,12 +16,12 @@ const provideServices = (bottle, connect) => {
|
||||
bottle.serviceFactory('VisitsStats', VisitsStats, 'VisitsParser', 'OpenMapModalBtn');
|
||||
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsStats');
|
||||
bottle.decorator('ShortUrlVisits', connect(
|
||||
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
|
||||
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
|
||||
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats', 'ColorGenerator');
|
||||
bottle.decorator('TagVisits', connect(
|
||||
[ 'tagVisits', 'mercureInfo', 'settings' ],
|
||||
[ 'tagVisits', 'mercureInfo' ],
|
||||
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ]
|
||||
));
|
||||
|
||||
|
||||
@@ -11,12 +11,11 @@ describe('helpers', () => {
|
||||
const onTokenExpired = jest.fn();
|
||||
|
||||
it.each([
|
||||
[{ loading: true, error: false }, { enabled: true }],
|
||||
[{ loading: false, error: true }, { enabled: true }],
|
||||
[{ loading: true, error: true }, { enabled: true }],
|
||||
[{ loading: false, error: false }, { enabled: false }],
|
||||
])('does not bind an EventSource when disabled, loading or error', (mercureInfo, realTimeUpdates) => {
|
||||
bindToMercureTopic(mercureInfo, realTimeUpdates)();
|
||||
[{ loading: true, error: false }],
|
||||
[{ loading: false, error: true }],
|
||||
[{ loading: true, error: true }],
|
||||
])('does not bind an EventSource when loading or error', (mercureInfo) => {
|
||||
bindToMercureTopic(mercureInfo)();
|
||||
|
||||
expect(EventSource).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
@@ -36,7 +35,7 @@ describe('helpers', () => {
|
||||
error: false,
|
||||
mercureHubUrl,
|
||||
token,
|
||||
}, { enabled: true }, topic, onMessage, onTokenExpired)();
|
||||
}, topic, onMessage, onTokenExpired)();
|
||||
|
||||
expect(EventSource).toHaveBeenCalledWith(hubUrl, {
|
||||
headers: {
|
||||
|
||||
@@ -40,14 +40,31 @@ describe('mercureInfoReducer', () => {
|
||||
mercureInfo: jest.fn(() => result),
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({});
|
||||
const createGetStateMock = (enabled) => jest.fn(() => ({
|
||||
settings: {
|
||||
realTimeUpdates: { enabled },
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('dispatches error when real time updates are disabled', async () => {
|
||||
const apiClientMock = createApiClientMock(Promise.resolve(mercureInfo));
|
||||
const getState = createGetStateMock(false);
|
||||
|
||||
await loadMercureInfo(() => apiClientMock)()(dispatch, getState);
|
||||
|
||||
expect(apiClientMock.mercureInfo).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO_ERROR });
|
||||
});
|
||||
|
||||
it('calls API on success', async () => {
|
||||
const apiClientMock = createApiClientMock(Promise.resolve(mercureInfo));
|
||||
const getState = createGetStateMock(true);
|
||||
|
||||
await loadMercureInfo(() => apiClientMock)()(dispatch, getState());
|
||||
await loadMercureInfo(() => apiClientMock)()(dispatch, getState);
|
||||
|
||||
expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
@@ -58,8 +75,9 @@ describe('mercureInfoReducer', () => {
|
||||
it('throws error on failure', async () => {
|
||||
const error = 'Error';
|
||||
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||
const getState = createGetStateMock(true);
|
||||
|
||||
await loadMercureInfo(() => apiClientMock)()(dispatch, getState());
|
||||
await loadMercureInfo(() => apiClientMock)()(dispatch, getState);
|
||||
|
||||
expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -9,7 +9,6 @@ describe('<ShortUrlsList />', () => {
|
||||
const ShortUrlsRow = () => '';
|
||||
const listShortUrlsMock = jest.fn();
|
||||
const resetShortUrlParamsMock = jest.fn();
|
||||
const realTimeUpdates = { enabled: true };
|
||||
|
||||
const ShortUrlsList = shortUrlsListCreator(ShortUrlsRow);
|
||||
|
||||
@@ -38,7 +37,6 @@ describe('<ShortUrlsList />', () => {
|
||||
]
|
||||
}
|
||||
mercureInfo={{ loading: true }}
|
||||
settings={{ realTimeUpdates }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('<TagsList />', () => {
|
||||
const TagsList = createTagsList(TagCard);
|
||||
|
||||
wrapper = shallow(
|
||||
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} settings={{}} />
|
||||
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
|
||||
@@ -14,7 +14,6 @@ describe('<ShortUrlVisits />', () => {
|
||||
const history = {
|
||||
goBack: jest.fn(),
|
||||
};
|
||||
const realTimeUpdates = { enabled: true };
|
||||
const VisitsStats = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -31,7 +30,6 @@ describe('<ShortUrlVisits />', () => {
|
||||
shortUrlDetail={{}}
|
||||
cancelGetShortUrlVisits={identity}
|
||||
matchMedia={() => ({ matches: false })}
|
||||
settings={{ realTimeUpdates }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ describe('<TagVisits />', () => {
|
||||
const history = {
|
||||
goBack: jest.fn(),
|
||||
};
|
||||
const realTimeUpdates = { enabled: true };
|
||||
const VisitsStats = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -26,7 +25,6 @@ describe('<TagVisits />', () => {
|
||||
history={history}
|
||||
tagVisits={{ loading: true, visits: [] }}
|
||||
cancelGetTagVisits={identity}
|
||||
settings={{ realTimeUpdates }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import { identity } from 'ramda';
|
||||
import { Card, Progress } from 'reactstrap';
|
||||
import createVisitStats from '../../src/visits/VisitsStats';
|
||||
import Message from '../../src/utils/Message';
|
||||
import GraphCard from '../../src/visits/GraphCard';
|
||||
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
||||
import GraphCard from '../../src/visits/helpers/GraphCard';
|
||||
import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph';
|
||||
import DateRangeRow from '../../src/utils/DateRangeRow';
|
||||
|
||||
describe('<VisitStats />', () => {
|
||||
|
||||
@@ -2,9 +2,9 @@ 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';
|
||||
import DefaultChart from '../../../src/visits/helpers/DefaultChart';
|
||||
|
||||
describe('<GraphCard />', () => {
|
||||
describe('<DefaultChart />', () => {
|
||||
let wrapper;
|
||||
const stats = {
|
||||
foo: 123,
|
||||
@@ -14,16 +14,17 @@ describe('<GraphCard />', () => {
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
it('renders Doughnut when is not a bar chart', () => {
|
||||
wrapper = shallow(<GraphCard title="The chart" stats={stats} />);
|
||||
wrapper = shallow(<DefaultChart title="The chart" stats={stats} />);
|
||||
const doughnut = wrapper.find(Doughnut);
|
||||
const horizontal = wrapper.find(HorizontalBar);
|
||||
const cols = wrapper.find('.col-sm-12');
|
||||
|
||||
expect(doughnut).toHaveLength(1);
|
||||
expect(horizontal).toHaveLength(0);
|
||||
|
||||
const { labels, datasets } = doughnut.prop('data');
|
||||
const [{ title, data, backgroundColor, borderColor }] = datasets;
|
||||
const { legend, scales } = doughnut.prop('options');
|
||||
const { legend, legendCallback, scales } = doughnut.prop('options');
|
||||
|
||||
expect(title).toEqual('The chart');
|
||||
expect(labels).toEqual(keys(stats));
|
||||
@@ -43,24 +44,28 @@ describe('<GraphCard />', () => {
|
||||
'#463730',
|
||||
]);
|
||||
expect(borderColor).toEqual('white');
|
||||
expect(legend).toEqual({ position: 'right' });
|
||||
expect(legend).toEqual({ display: false });
|
||||
expect(typeof legendCallback).toEqual('function');
|
||||
expect(scales).toBeUndefined();
|
||||
expect(cols).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders HorizontalBar when is not a bar chart', () => {
|
||||
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} />);
|
||||
wrapper = shallow(<DefaultChart isBarChart title="The chart" stats={stats} />);
|
||||
const doughnut = wrapper.find(Doughnut);
|
||||
const horizontal = wrapper.find(HorizontalBar);
|
||||
const cols = wrapper.find('.col-sm-12');
|
||||
|
||||
expect(doughnut).toHaveLength(0);
|
||||
expect(horizontal).toHaveLength(1);
|
||||
|
||||
const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data');
|
||||
const { legend, scales } = horizontal.prop('options');
|
||||
const { legend, legendCallback, 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(legendCallback).toEqual(false);
|
||||
expect(scales).toEqual({
|
||||
xAxes: [
|
||||
{
|
||||
@@ -70,6 +75,7 @@ describe('<GraphCard />', () => {
|
||||
],
|
||||
yAxes: [{ stacked: true }],
|
||||
});
|
||||
expect(cols).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -79,7 +85,7 @@ describe('<GraphCard />', () => {
|
||||
[{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]],
|
||||
[ undefined, [ 123, 456 ], undefined ],
|
||||
])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => {
|
||||
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} highlightedStats={highlightedStats} />);
|
||||
wrapper = shallow(<DefaultChart isBarChart title="The chart" stats={stats} highlightedStats={highlightedStats} />);
|
||||
const horizontal = wrapper.find(HorizontalBar);
|
||||
|
||||
const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data');
|
||||
49
test/visits/helpers/GraphCard.test.js
Normal file
49
test/visits/helpers/GraphCard.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Card, CardBody, CardHeader, CardFooter } from 'reactstrap';
|
||||
import GraphCard from '../../../src/visits/helpers/GraphCard';
|
||||
import DefaultChart from '../../../src/visits/helpers/DefaultChart';
|
||||
|
||||
describe('<GraphCard />', () => {
|
||||
let wrapper;
|
||||
const createWrapper = (title = '', footer) => {
|
||||
wrapper = shallow(<GraphCard title={title} footer={footer} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
|
||||
it('renders expected components', () => {
|
||||
const wrapper = createWrapper();
|
||||
const card = wrapper.find(Card);
|
||||
const header = wrapper.find(CardHeader);
|
||||
const body = wrapper.find(CardBody);
|
||||
const chart = wrapper.find(DefaultChart);
|
||||
const footer = wrapper.find(CardFooter);
|
||||
|
||||
expect(card).toHaveLength(1);
|
||||
expect(header).toHaveLength(1);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(chart).toHaveLength(1);
|
||||
expect(footer).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 'the title', 'the title' ],
|
||||
[ () => 'the title from func', 'the title from func' ],
|
||||
])('properly renders title by parsing provided value', (title, expectedTitle) => {
|
||||
const wrapper = createWrapper(title);
|
||||
const header = wrapper.find(CardHeader);
|
||||
|
||||
expect(header.html()).toContain(expectedTitle);
|
||||
});
|
||||
|
||||
it('renders footer only when provided', () => {
|
||||
const wrapper = createWrapper('', 'the footer');
|
||||
const footer = wrapper.find(CardFooter);
|
||||
|
||||
expect(footer).toHaveLength(1);
|
||||
expect(footer.html()).toContain('the footer');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { CardHeader, DropdownItem } from 'reactstrap';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import moment from 'moment';
|
||||
import LineChartCard from '../../../src/visits/helpers/LineChartCard';
|
||||
import Checkbox from '../../../src/utils/Checkbox';
|
||||
|
||||
@@ -22,19 +23,27 @@ describe('<LineChartCard />', () => {
|
||||
expect(header.html()).toContain('Cool title');
|
||||
});
|
||||
|
||||
it('renders group menu and selects active grouping item', () => {
|
||||
const wrapper = createWrapper();
|
||||
it.each([
|
||||
[[], 'monthly' ],
|
||||
[[{ date: moment().subtract(1, 'day').format() }], 'hourly' ],
|
||||
[[{ date: moment().subtract(3, 'day').format() }], 'daily' ],
|
||||
[[{ date: moment().subtract(2, 'month').format() }], 'weekly' ],
|
||||
[[{ date: moment().subtract(6, 'month').format() }], 'weekly' ],
|
||||
[[{ date: moment().subtract(7, 'month').format() }], 'monthly' ],
|
||||
[[{ date: moment().subtract(1, 'year').format() }], 'monthly' ],
|
||||
])('renders group menu and selects proper grouping item based on visits dates', (visits, expectedActiveItem) => {
|
||||
const wrapper = createWrapper(visits);
|
||||
const items = wrapper.find(DropdownItem);
|
||||
|
||||
expect(items).toHaveLength(4);
|
||||
expect(items.at(0).prop('children')).toEqual('Month');
|
||||
expect(items.at(0).prop('active')).toEqual(true);
|
||||
expect(items.at(0).prop('active')).toEqual(expectedActiveItem === 'monthly');
|
||||
expect(items.at(1).prop('children')).toEqual('Week');
|
||||
expect(items.at(1).prop('active')).toEqual(false);
|
||||
expect(items.at(1).prop('active')).toEqual(expectedActiveItem === 'weekly');
|
||||
expect(items.at(2).prop('children')).toEqual('Day');
|
||||
expect(items.at(2).prop('active')).toEqual(false);
|
||||
expect(items.at(2).prop('active')).toEqual(expectedActiveItem === 'daily');
|
||||
expect(items.at(3).prop('children')).toEqual('Hour');
|
||||
expect(items.at(3).prop('active')).toEqual(false);
|
||||
expect(items.at(3).prop('active')).toEqual(expectedActiveItem === 'hourly');
|
||||
});
|
||||
|
||||
it('renders chart with expected options', () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { keys, range, values } from 'ramda';
|
||||
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
||||
import GraphCard from '../../src/visits/GraphCard';
|
||||
import SortingDropdown from '../../src/utils/SortingDropdown';
|
||||
import PaginationDropdown from '../../src/utils/PaginationDropdown';
|
||||
import { rangeOf } from '../../src/utils/utils';
|
||||
import SortableBarGraph from '../../../src/visits/helpers/SortableBarGraph';
|
||||
import GraphCard from '../../../src/visits/helpers/GraphCard';
|
||||
import SortingDropdown from '../../../src/utils/SortingDropdown';
|
||||
import PaginationDropdown from '../../../src/utils/PaginationDropdown';
|
||||
import { rangeOf } from '../../../src/utils/utils';
|
||||
|
||||
describe('<SortableBarGraph />', () => {
|
||||
let wrapper;
|
||||
Reference in New Issue
Block a user