From 260ed3041aaaf9b79663c88f5ea861130f19a91f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 4 Sep 2020 18:43:26 +0200 Subject: [PATCH] Finished migrating visits helpers to TS --- src/visits/helpers/DefaultChart.tsx | 6 +- .../{LineChartCard.js => LineChartCard.tsx} | 67 ++++++++------- ...rtableBarGraph.js => SortableBarGraph.tsx} | 86 +++++++++---------- src/visits/types/index.ts | 2 + ...artCard.test.js => LineChartCard.test.tsx} | 26 +++--- ...raph.test.js => SortableBarGraph.test.tsx} | 35 ++++---- ...itsParser.test.js => VisitsParser.test.ts} | 26 +++--- 7 files changed, 130 insertions(+), 118 deletions(-) rename src/visits/helpers/{LineChartCard.js => LineChartCard.tsx} (71%) rename src/visits/helpers/{SortableBarGraph.js => SortableBarGraph.tsx} (66%) rename test/visits/helpers/{LineChartCard.test.js => LineChartCard.test.tsx} (78%) rename test/visits/helpers/{SortableBarGraph.test.js => SortableBarGraph.test.tsx} (70%) rename test/visits/services/{VisitsParser.test.js => VisitsParser.test.ts} (93%) diff --git a/src/visits/helpers/DefaultChart.tsx b/src/visits/helpers/DefaultChart.tsx index 47d2250e..1fa9a3bc 100644 --- a/src/visits/helpers/DefaultChart.tsx +++ b/src/visits/helpers/DefaultChart.tsx @@ -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; diff --git a/src/visits/helpers/LineChartCard.js b/src/visits/helpers/LineChartCard.tsx similarity index 71% rename from src/visits/helpers/LineChartCard.js rename to src/visits/helpers/LineChartCard.tsx index 3daeb969..fc57ce37 100644 --- a/src/visits/helpers/LineChartCard.js +++ b/src/visits/helpers/LineChartCard.tsx @@ -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 = { monthly: 'Month', weekly: 'Week', daily: 'Day', hourly: 'Hour', }; -const STEP_TO_DATE_UNIT_MAP = { +const STEP_TO_DATE_UNIT_MAP: Record = { hourly: 'hour', daily: 'day', weekly: 'week', monthly: 'month', }; -const STEP_TO_DATE_FORMAT = { +const STEP_TO_DATE_FORMAT: Record 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([ + [ () => 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'; + return matcher() ?? 'monthly'; }; -const groupVisitsByStep = (step, visits) => visits.reduce((acc, visit) => { +const groupVisitsByStep = (step: Step, visits: Visit[]): Stats => visits.reduce((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( 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 {Object.entries(STEPS_MAP).map(([ value, menuText ]) => ( - setStep(value)}> + setStep(value as Step)}> {menuText} ))} @@ -179,6 +186,4 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S ); }; -LineChartCard.propTypes = propTypes; - export default LineChartCard; diff --git a/src/visits/helpers/SortableBarGraph.js b/src/visits/helpers/SortableBarGraph.tsx similarity index 66% rename from src/visits/helpers/SortableBarGraph.js rename to src/visits/helpers/SortableBarGraph.tsx index 7ff4c123..286a48ed 100644 --- a/src/visits/helpers/SortableBarGraph.js +++ b/src/visits/helpers/SortableBarGraph.tsx @@ -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; + 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) => { const pairs = toPairs(stats); const sortedPairs = !order.orderField ? pairs : sortBy( - pipe( - prop(order.orderField === head(keys(sortingItems)) ? 0 : 1), + pipe( + 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) => + ; + const determineStats = (stats: Stats, highlightedStats: Stats | undefined, sortingItems: Record) => { 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) => - ; 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 = () => ( {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); + }} /> - {withPagination && keys(stats).length > 50 && ( + {withPagination && Object.keys(stats).length > 50 && (
setItemsPerPage(itemsPerPage) || setCurrentPage(1)} + setValue={(itemsPerPage) => { + setItemsPerPage(itemsPerPage); + setCurrentPage(1); + }} />
)} @@ -141,6 +143,4 @@ const SortableBarGraph = ({ ); }; -SortableBarGraph.propTypes = propTypes; - export default SortableBarGraph; diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 0b553d4e..1400ee77 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -80,6 +80,8 @@ export interface CreateVisit { export type Stats = Record; +export type StatsRow = [string, number]; + export interface CityStats { cityName: string; count: number; diff --git a/test/visits/helpers/LineChartCard.test.js b/test/visits/helpers/LineChartCard.test.tsx similarity index 78% rename from test/visits/helpers/LineChartCard.test.js rename to test/visits/helpers/LineChartCard.test.tsx index 348ba365..1fc0113a 100644 --- a/test/visits/helpers/LineChartCard.test.js +++ b/test/visits/helpers/LineChartCard.test.tsx @@ -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('', () => { - let wrapper; - const createWrapper = (visits = [], highlightedVisits = []) => { + let wrapper: ShallowWrapper; + const createWrapper = (visits: Visit[] = [], highlightedVisits: Visit[] = []) => { wrapper = shallow(); return wrapper; }; - afterEach(() => wrapper && wrapper.unmount()); + afterEach(() => wrapper?.unmount()); it('renders provided title', () => { const wrapper = createWrapper(); @@ -32,7 +34,7 @@ describe('', () => { [[{ 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))); const items = wrapper.find(DropdownItem); expect(items).toHaveLength(4); @@ -73,24 +75,24 @@ describe('', () => { }); it.each([ - [[{}], [], 1 ], - [[{}], [{}], 2 ], + [[ Mock.of({}) ], [], 1 ], + [[ Mock.of({}) ], [ Mock.of({}) ], 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({ date: '2016-04-01' }), + Mock.of({ 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); }); }); diff --git a/test/visits/helpers/SortableBarGraph.test.js b/test/visits/helpers/SortableBarGraph.test.tsx similarity index 70% rename from test/visits/helpers/SortableBarGraph.test.js rename to test/visits/helpers/SortableBarGraph.test.tsx index 4ea06650..e22fa0a4 100644 --- a/test/visits/helpers/SortableBarGraph.test.js +++ b/test/visits/helpers/SortableBarGraph.test.tsx @@ -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('', () => { - let wrapper; + let wrapper: ShallowWrapper; const sortingItems = { name: 'Name', amount: 'Amount', @@ -30,7 +31,7 @@ describe('', () => { 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('', () => { }); 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('', () => { }); 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((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('', () => { }); it('renders extra header content', () => { - wrapper = shallow( + const wrapper = shallow( { - const visits = [ - { + const visits: Visit[] = [ + Mock.of({ 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({ 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({ 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({ 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({ 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));