diff --git a/src/utils/DropdownBtn.tsx b/src/utils/DropdownBtn.tsx index d1cf6f6b..b658c218 100644 --- a/src/utils/DropdownBtn.tsx +++ b/src/utils/DropdownBtn.tsx @@ -7,16 +7,20 @@ export interface DropdownBtnProps { text: string; disabled?: boolean; className?: string; + dropdownClassName?: string; + right?: boolean; } -export const DropdownBtn: FC = ({ text, disabled = false, className = '', children }) => { +export const DropdownBtn: FC = ( + { text, disabled = false, className = '', children, dropdownClassName, right = false }, +) => { const [ isOpen, toggle ] = useToggle(); const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; return ( - + {text} - {children} + {children} ); }; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 57c10e4b..8e5da643 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -1,4 +1,4 @@ -import { countBy, isEmpty, prop, propEq, values } from 'ramda'; +import { countBy, filter, isEmpty, pipe, prop, propEq, values } from 'ramda'; import { useState, useEffect, useMemo, FC } from 'react'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -19,9 +19,10 @@ import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; import VisitsTable from './VisitsTable'; -import { NormalizedVisit, Stats, VisitsInfo } from './types'; +import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, Stats, Visit, VisitsInfo } from './types'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; +import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; import './VisitsStats.scss'; export interface VisitsStatsProps { @@ -54,6 +55,11 @@ const sections: Record = { const highlightedVisitsToStats = (highlightedVisits: NormalizedVisit[], property: HighlightableProps): Stats => countBy(prop(property), highlightedVisits); +const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe( + normalizeVisits, + filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type), +)(visits); + let selectedBar: string | undefined; const VisitsNavLink: FC = ({ subPath, title, icon, to }) => ( @@ -76,6 +82,7 @@ const VisitsStats: FC = ( const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); + const [ orphanVisitType, setOrphanVisitType ] = useState(); const buildSectionUrl = (subPath?: string) => { const query = domain ? `?domain=${domain}` : ''; @@ -83,7 +90,10 @@ const VisitsStats: FC = ( return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; }; const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; - const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); + const normalizedVisits = useMemo( + () => normalizeAndFilterVisits(visits, orphanVisitType), + [ visits, orphanVisitType ], + ); const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( () => processStatsFromVisits(normalizedVisits), [ normalizedVisits ], @@ -256,12 +266,24 @@ const VisitsStats: FC = (
- +
+
+ +
+ {isOrphanVisits && ( + + )} +
{visits.length > 0 && (
diff --git a/src/visits/helpers/OrphanVisitTypeDropdown.tsx b/src/visits/helpers/OrphanVisitTypeDropdown.tsx new file mode 100644 index 00000000..61273c14 --- /dev/null +++ b/src/visits/helpers/OrphanVisitTypeDropdown.tsx @@ -0,0 +1,26 @@ +import { DropdownItem } from 'reactstrap'; +import { OrphanVisitType } from '../types'; +import { DropdownBtn } from '../../utils/DropdownBtn'; + +interface OrphanVisitTypeDropdownProps { + onChange: (type: OrphanVisitType | undefined) => void; + selected?: OrphanVisitType | undefined; + className?: string; + text: string; +} + +export const OrphanVisitTypeDropdown = ({ onChange, selected, text, className }: OrphanVisitTypeDropdownProps) => ( + + onChange('base_url')}> + Base URL + + onChange('invalid_short_url')}> + Invalid short URL + + onChange('regular_404')}> + Regular 404 + + + onChange(undefined)}>Clear selection + +); diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 813a8767..bb8f7f68 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -20,7 +20,7 @@ export interface VisitsLoadFailedAction extends Action { errorData?: ProblemDetailsError; } -type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; +export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; interface VisitLocation { countryCode: string | null; diff --git a/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx new file mode 100644 index 00000000..c41b340a --- /dev/null +++ b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx @@ -0,0 +1,56 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { OrphanVisitType } from '../../../src/visits/types'; +import { OrphanVisitTypeDropdown } from '../../../src/visits/helpers/OrphanVisitTypeDropdown'; + +describe('', () => { + let wrapper: ShallowWrapper; + const onChange = jest.fn(); + const createWrapper = (selected?: OrphanVisitType) => { + wrapper = shallow(); + + return wrapper; + }; + + beforeEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it('has provided text', () => { + const wrapper = createWrapper(); + + expect(wrapper.prop('text')).toEqual('The text'); + }); + + it.each([ + [ 'base_url' as OrphanVisitType, 0, 1 ], + [ 'invalid_short_url' as OrphanVisitType, 1, 1 ], + [ 'regular_404' as OrphanVisitType, 2, 1 ], + [ undefined, -1, 0 ], + ])('sets expected item as active', (selected, expectedSelectedIndex, expectedActiveItems) => { + const wrapper = createWrapper(selected); + const items = wrapper.find(DropdownItem); + const activeItem = items.filterWhere((item) => !!item.prop('active')); + + expect.assertions(expectedActiveItems + 1); + expect(activeItem).toHaveLength(expectedActiveItems); + items.forEach((item, index) => { + if (item.prop('active')) { + expect(index).toEqual(expectedSelectedIndex); + } + }); + }); + + it.each([ + [ 0, 'base_url' ], + [ 1, 'invalid_short_url' ], + [ 2, 'regular_404' ], + [ 4, undefined ], + ])('invokes onChange with proper type when an item is clicked', (index, expectedType) => { + const wrapper = createWrapper(); + const itemToClick = wrapper.find(DropdownItem).at(index); + + itemToClick.simulate('click'); + + expect(onChange).toHaveBeenCalledWith(expectedType); + }); +});