Finished migrating visits helpers to TS

This commit is contained in:
Alejandro Celaya 2020-09-04 18:43:26 +02:00
parent 8a146021dd
commit 260ed3041a
7 changed files with 130 additions and 118 deletions

View file

@ -47,15 +47,15 @@ const generateGraphData = (
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
borderWidth: 2,
},
(highlightedData && {
highlightedData && {
title,
label: highlightedLabel ?? 'Selected',
data: highlightedData,
backgroundColor: 'rgba(247, 127, 40, 0.4)',
borderColor: '#F77F28',
borderWidth: 2,
}) as unknown as ChartDataSets,
].filter(Boolean),
},
].filter(Boolean) as ChartDataSets[],
});
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;

View file

@ -1,5 +1,4 @@
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import {
Card,
CardHeader,
@ -12,35 +11,38 @@ import {
import { Line } from 'react-chartjs-2';
import { always, cond, reverse } from 'ramda';
import moment from 'moment';
import { VisitType } from '../types';
import { ChartData, ChartDataSets } from 'chart.js';
import { Stats, Visit } from '../types';
import { fillTheGaps } from '../../utils/helpers/visits';
import './LineChartCard.scss';
import { useToggle } from '../../utils/helpers/hooks';
import { rangeOf } from '../../utils/utils';
import ToggleSwitch from '../../utils/ToggleSwitch';
import './LineChartCard.scss';
const propTypes = {
title: PropTypes.string,
highlightedLabel: PropTypes.string,
visits: PropTypes.arrayOf(VisitType),
highlightedVisits: PropTypes.arrayOf(VisitType),
};
interface LineChartCardProps {
title: string;
highlightedLabel?: string;
visits: Visit[];
highlightedVisits: Visit[];
}
const STEPS_MAP = {
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
const STEPS_MAP: Record<Step, string> = {
monthly: 'Month',
weekly: 'Week',
daily: 'Day',
hourly: 'Hour',
};
const STEP_TO_DATE_UNIT_MAP = {
const STEP_TO_DATE_UNIT_MAP: Record<Step, moment.unitOfTime.Diff> = {
hourly: 'hour',
daily: 'day',
weekly: 'week',
monthly: 'month',
};
const STEP_TO_DATE_FORMAT = {
const STEP_TO_DATE_FORMAT: Record<Step, (date: moment.Moment | string) => string> = {
hourly: (date) => moment(date).format('YYYY-MM-DD HH:00'),
daily: (date) => moment(date).format('YYYY-MM-DD'),
weekly(date) {
@ -52,19 +54,19 @@ const STEP_TO_DATE_FORMAT = {
monthly: (date) => moment(date).format('YYYY-MM'),
};
const determineInitialStep = (oldestVisitDate) => {
const determineInitialStep = (oldestVisitDate: string): Step => {
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
const matcher = cond<never, Step | undefined>([
[ () => now.diff(oldestDate, 'day') <= 2, always<Step>('hourly') ], // Less than 2 days
[ () => now.diff(oldestDate, 'month') <= 1, always<Step>('daily') ], // Between 2 days and 1 month
[ () => now.diff(oldestDate, 'month') <= 6, always<Step>('weekly') ], // Between 1 and 6 months
]);
return matcher() || 'monthly';
return matcher() ?? 'monthly';
};
const groupVisitsByStep = (step, visits) => visits.reduce((acc, visit) => {
const groupVisitsByStep = (step: Step, visits: Visit[]): Stats => visits.reduce<Stats>((acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](visit.date);
acc[key] = acc[key] ? acc[key] + 1 : 1;
@ -72,7 +74,7 @@ const groupVisitsByStep = (step, visits) => visits.reduce((acc, visit) => {
return acc;
}, {});
const generateLabels = (step, visits) => {
const generateLabels = (step: Step, visits: Visit[]): string[] => {
const unit = STEP_TO_DATE_UNIT_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step];
const newerDate = moment(visits[0].date);
@ -85,9 +87,14 @@ const generateLabels = (step, visits) => {
];
};
const generateLabelsAndGroupedVisits = (visits, groupedVisitsWithGaps, step, skipNoElements) => {
const generateLabelsAndGroupedVisits = (
visits: Visit[],
groupedVisitsWithGaps: Stats,
step: Step,
skipNoElements: boolean,
): [string[], number[]] => {
if (skipNoElements) {
return [ Object.keys(groupedVisitsWithGaps), groupedVisitsWithGaps ];
return [ Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps) ];
}
const labels = generateLabels(step, visits);
@ -95,17 +102,17 @@ const generateLabelsAndGroupedVisits = (visits, groupedVisitsWithGaps, step, ski
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
};
const generateDataset = (stats, label, color) => ({
const generateDataset = (data: number[], label: string, color: string): ChartDataSets => ({
label,
data: Object.values(stats),
data,
fill: false,
lineTension: 0.2,
borderColor: color,
backgroundColor: color,
});
const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'Selected' }) => {
const [ step, setStep ] = useState(
const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'Selected' }: LineChartCardProps) => {
const [ step, setStep ] = useState<Step>(
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
);
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
@ -120,12 +127,12 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
[ highlightedVisits, step, labels ],
);
const data = {
const data: ChartData = {
labels,
datasets: [
generateDataset(groupedVisits, 'Visits', '#4696e5'),
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, '#F77F28'),
].filter(Boolean),
].filter(Boolean) as ChartDataSets[],
};
const options = {
maintainAspectRatio: false,
@ -159,7 +166,7 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
</DropdownToggle>
<DropdownMenu right>
{Object.entries(STEPS_MAP).map(([ value, menuText ]) => (
<DropdownItem key={value} active={step === value} onClick={() => setStep(value)}>
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
{menuText}
</DropdownItem>
))}
@ -179,6 +186,4 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
);
};
LineChartCard.propTypes = propTypes;
export default LineChartCard;

View file

@ -1,27 +1,23 @@
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 { fromPairs, pipe, 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 { OrderDir, rangeOf } from '../../utils/utils';
import { roundTen } from '../../utils/helpers/numbers';
import SimplePaginator from '../../common/SimplePaginator';
import { Stats, StatsRow } from '../types';
import GraphCard from './GraphCard';
import { DefaultChartProps } from './DefaultChart';
const propTypes = {
stats: PropTypes.object.isRequired,
highlightedStats: PropTypes.object,
highlightedLabel: PropTypes.string,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool,
onClick: PropTypes.func,
};
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value;
const pickKeyFromPair = ([ key ]: StatsRow) => key;
const pickValueFromPair = ([ , value ]: StatsRow) => value;
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickKeyFromPair = ([ key ]) => key;
const pickValueFromPair = ([ , value ]) => value;
interface SortableBarGraphProps extends DefaultChartProps {
sortingItems: Record<string, string>;
withPagination?: boolean;
extraHeaderContent?: Function;
}
const SortableBarGraph = ({
stats,
@ -31,19 +27,19 @@ const SortableBarGraph = ({
extraHeaderContent,
withPagination = true,
...rest
}) => {
const [ order, setOrder ] = useState({
}: SortableBarGraphProps) => {
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
orderField: undefined,
orderDir: undefined,
});
const [ currentPage, setCurrentPage ] = useState(1);
const [ itemsPerPage, setItemsPerPage ] = useState(50);
const getSortedPairsForStats = (stats, sortingItems) => {
const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => {
const pairs = toPairs(stats);
const sortedPairs = !order.orderField ? pairs : sortBy(
pipe(
prop(order.orderField === head(keys(sortingItems)) ? 0 : 1),
pipe<StatsRow, string | number, string | number>(
order.orderField === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
toLowerIfString,
),
pairs,
@ -51,7 +47,21 @@ const SortableBarGraph = ({
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
};
const determineStats = (stats, highlightedStats, sortingItems) => {
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
const page = pages[currentPage - 1];
if (currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i): StatsRow => [ `hidden_${i}`, 0 ]) ];
};
const renderPagination = (pagesCount: number) =>
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
const determineStats = (stats: Stats, highlightedStats: Stats | undefined, sortingItems: Record<string, string>) => {
const sortedPairs = getSortedPairsForStats(stats, sortingItems);
const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values
@ -76,27 +86,13 @@ const SortableBarGraph = ({
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
};
};
const determineCurrentPagePairs = (pages) => {
const page = pages[currentPage - 1];
if (currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
};
const renderPagination = (pagesCount) =>
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
stats,
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
highlightedStats && Object.keys(highlightedStats).length > 0 ? highlightedStats : undefined,
sortingItems,
);
const activeCities = keys(currentPageStats);
const activeCities = Object.keys(currentPageStats);
const computeTitle = () => (
<React.Fragment>
{title}
@ -107,16 +103,22 @@ const SortableBarGraph = ({
items={sortingItems}
orderField={order.orderField}
orderDir={order.orderDir}
onChange={(orderField, orderDir) => setOrder({ orderField, orderDir }) || setCurrentPage(1)}
onChange={(orderField, orderDir) => {
setOrder({ orderField, orderDir });
setCurrentPage(1);
}}
/>
</div>
{withPagination && keys(stats).length > 50 && (
{withPagination && Object.keys(stats).length > 50 && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm p-0 mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={itemsPerPage}
setValue={(itemsPerPage) => setItemsPerPage(itemsPerPage) || setCurrentPage(1)}
setValue={(itemsPerPage) => {
setItemsPerPage(itemsPerPage);
setCurrentPage(1);
}}
/>
</div>
)}
@ -141,6 +143,4 @@ const SortableBarGraph = ({
);
};
SortableBarGraph.propTypes = propTypes;
export default SortableBarGraph;

View file

@ -80,6 +80,8 @@ export interface CreateVisit {
export type Stats = Record<string, number>;
export type StatsRow = [string, number];
export interface CityStats {
cityName: string;
count: number;

View file

@ -1,20 +1,22 @@
import React from 'react';
import { shallow } from 'enzyme';
import { shallow, ShallowWrapper } from 'enzyme';
import { CardHeader, DropdownItem } from 'reactstrap';
import { Line } from 'react-chartjs-2';
import moment from 'moment';
import { Mock } from 'ts-mockery';
import LineChartCard from '../../../src/visits/helpers/LineChartCard';
import ToggleSwitch from '../../../src/utils/ToggleSwitch';
import { Visit } from '../../../src/visits/types';
describe('<LineChartCard />', () => {
let wrapper;
const createWrapper = (visits = [], highlightedVisits = []) => {
let wrapper: ShallowWrapper;
const createWrapper = (visits: Visit[] = [], highlightedVisits: Visit[] = []) => {
wrapper = shallow(<LineChartCard title="Cool title" visits={visits} highlightedVisits={highlightedVisits} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
afterEach(() => wrapper?.unmount());
it('renders provided title', () => {
const wrapper = createWrapper();
@ -32,7 +34,7 @@ describe('<LineChartCard />', () => {
[[{ 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 wrapper = createWrapper(visits.map((visit) => Mock.of<Visit>(visit)));
const items = wrapper.find(DropdownItem);
expect(items).toHaveLength(4);
@ -73,24 +75,24 @@ describe('<LineChartCard />', () => {
});
it.each([
[[{}], [], 1 ],
[[{}], [{}], 2 ],
[[ Mock.of<Visit>({}) ], [], 1 ],
[[ Mock.of<Visit>({}) ], [ Mock.of<Visit>({}) ], 2 ],
])('renders chart with expected data', (visits, highlightedVisits, expectedLines) => {
const wrapper = createWrapper(visits, highlightedVisits);
const chart = wrapper.find(Line);
const { datasets } = chart.prop('data');
const { datasets } = chart.prop('data') as any;
expect(datasets).toHaveLength(expectedLines);
});
it('includes stats for visits with no dates if selected', () => {
const wrapper = createWrapper([
{ date: '2016-04-01' },
{ date: '2016-01-01' },
Mock.of<Visit>({ date: '2016-04-01' }),
Mock.of<Visit>({ date: '2016-01-01' }),
]);
expect(wrapper.find(Line).prop('data').labels).toHaveLength(2);
expect((wrapper.find(Line).prop('data') as any).labels).toHaveLength(2);
wrapper.find(ToggleSwitch).simulate('change');
expect(wrapper.find(Line).prop('data').labels).toHaveLength(4);
expect((wrapper.find(Line).prop('data') as any).labels).toHaveLength(4);
});
});

View file

@ -1,14 +1,15 @@
import React from 'react';
import { shallow } from 'enzyme';
import { keys, range, values } from 'ramda';
import { shallow, ShallowWrapper } from 'enzyme';
import { range } from 'ramda';
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';
import { OrderDir, rangeOf } from '../../../src/utils/utils';
import { Stats } from '../../../src/visits/types';
describe('<SortableBarGraph />', () => {
let wrapper;
let wrapper: ShallowWrapper;
const sortingItems = {
name: 'Name',
amount: 'Amount',
@ -30,7 +31,7 @@ describe('<SortableBarGraph />', () => {
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
afterEach(() => wrapper?.unmount());
it('renders stats unchanged when no ordering is set', () => {
const wrapper = createWrapper();
@ -40,19 +41,19 @@ describe('<SortableBarGraph />', () => {
});
describe('renders properly ordered stats when ordering is set', () => {
let assert;
let assert: (sortName: string, sortDir: OrderDir, keys: string[], values: number[], done: Function) => void;
beforeEach(() => {
const wrapper = createWrapper();
const dropdown = wrapper.renderProp('title')().find(SortingDropdown);
const dropdown = wrapper.renderProp('title' as never)().find(SortingDropdown);
assert = (sortName, sortDir, expectedKeys, expectedValues, done) => {
assert = (sortName: string, sortDir: OrderDir, keys: string[], values: number[], done: Function) => {
dropdown.prop('onChange')(sortName, sortDir);
setImmediate(() => {
const stats = wrapper.find(GraphCard).prop('stats');
expect(keys(stats)).toEqual(expectedKeys);
expect(values(stats)).toEqual(expectedValues);
expect(Object.keys(stats)).toEqual(keys);
expect(Object.values(stats)).toEqual(values);
done();
});
};
@ -65,28 +66,28 @@ describe('<SortableBarGraph />', () => {
});
describe('renders properly paginated stats when pagination is set', () => {
let assert;
let assert: (itemsPerPage: number, expectedStats: string[], done: Function) => void;
beforeEach(() => {
const wrapper = createWrapper(true, range(1, 159).reduce((accum, value) => {
const wrapper = createWrapper(true, range(1, 159).reduce<Stats>((accum, value) => {
accum[`key_${value}`] = value;
return accum;
}, {}));
const dropdown = wrapper.renderProp('title')().find(PaginationDropdown);
const dropdown = wrapper.renderProp('title' as never)().find(PaginationDropdown);
assert = (itemsPerPage, expectedStats, done) => {
assert = (itemsPerPage: number, expectedStats: string[], done: Function) => {
dropdown.prop('setValue')(itemsPerPage);
setImmediate(() => {
const stats = wrapper.find(GraphCard).prop('stats');
expect(keys(stats)).toEqual(expectedStats);
expect(Object.keys(stats)).toEqual(expectedStats);
done();
});
};
});
const buildExpected = (size) => [ 'Foo', 'Bar', ...rangeOf(size - 2, (i) => `key_${i}`) ];
const buildExpected = (size: number): string[] => [ 'Foo', 'Bar', ...rangeOf(size - 2, (i) => `key_${i}`) ];
it('50 items per page', (done) => assert(50, buildExpected(50), done));
it('100 items per page', (done) => assert(100, buildExpected(100), done));
@ -95,7 +96,7 @@ describe('<SortableBarGraph />', () => {
});
it('renders extra header content', () => {
wrapper = shallow(
const wrapper = shallow(
<span>
<SortableBarGraph
title="Foo"

View file

@ -1,8 +1,10 @@
import { Mock } from 'ts-mockery';
import { processStatsFromVisits, normalizeVisits } from '../../../src/visits/services/VisitsParser';
import { Visit, VisitsStats } from '../../../src/visits/types';
describe('VisitsParser', () => {
const visits = [
{
const visits: Visit[] = [
Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
referer: 'https://google.com',
visitLocation: {
@ -11,8 +13,8 @@ describe('VisitsParser', () => {
latitude: 123.45,
longitude: -543.21,
},
},
{
}),
Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
referer: 'https://google.com',
visitLocation: {
@ -21,14 +23,14 @@ describe('VisitsParser', () => {
latitude: 1029,
longitude: 6758,
},
},
{
}),
Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
visitLocation: {
countryName: 'Spain',
},
},
{
}),
Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
referer: 'https://m.facebook.com',
visitLocation: {
@ -37,14 +39,14 @@ describe('VisitsParser', () => {
latitude: 123.45,
longitude: -543.21,
},
},
{
}),
Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41',
},
}),
];
describe('processStatsFromVisits', () => {
let stats;
let stats: VisitsStats;
beforeAll(() => {
stats = processStatsFromVisits(normalizeVisits(visits));