diff --git a/src/utils/DropdownBtn.tsx b/src/utils/DropdownBtn.tsx index b658c218..7aeec368 100644 --- a/src/utils/DropdownBtn.tsx +++ b/src/utils/DropdownBtn.tsx @@ -9,18 +9,20 @@ export interface DropdownBtnProps { className?: string; dropdownClassName?: string; right?: boolean; + minWidth?: number; } export const DropdownBtn: FC = ( - { text, disabled = false, className = '', children, dropdownClassName, right = false }, + { text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth }, ) => { const [ isOpen, toggle ] = useToggle(); const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; + const style = { minWidth: minWidth && `${minWidth}px` }; return ( {text} - {children} + {children} ); }; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 628964ca..f0cad255 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -20,10 +20,10 @@ import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; import VisitsTable from './VisitsTable'; -import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types'; +import { NormalizedOrphanVisit, NormalizedVisit, VisitsInfo } from './types'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import { processStatsFromVisits } from './services/VisitsParser'; -import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; +import { VisitsFilter, VisitsFilterDropdown } from './helpers/VisitsFilterDropdown'; import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; import './VisitsStats.scss'; @@ -85,7 +85,7 @@ const VisitsStats: FC = ({ const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); - const [ orphanVisitType, setOrphanVisitType ] = useState(); + const [ visitsFilter, setVisitsFilter ] = useState({}); const buildSectionUrl = (subPath?: string) => { const query = domain ? `?domain=${domain}` : ''; @@ -93,10 +93,7 @@ const VisitsStats: FC = ({ return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; }; const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; - const normalizedVisits = useMemo( - () => normalizeAndFilterVisits(visits, orphanVisitType), - [ visits, orphanVisitType ], - ); + const normalizedVisits = useMemo(() => normalizeAndFilterVisits(visits, visitsFilter), [ visits, visitsFilter ]); const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( () => processStatsFromVisits(normalizedVisits), [ normalizedVisits ], @@ -282,14 +279,12 @@ const VisitsStats: FC = ({ onDatesChange={setDateRange} /> - {isOrphanVisits && ( - - )} + {visits.length > 0 && ( diff --git a/src/visits/helpers/OrphanVisitTypeDropdown.tsx b/src/visits/helpers/OrphanVisitTypeDropdown.tsx deleted file mode 100644 index 61273c14..00000000 --- a/src/visits/helpers/OrphanVisitTypeDropdown.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/helpers/VisitsFilterDropdown.tsx b/src/visits/helpers/VisitsFilterDropdown.tsx new file mode 100644 index 00000000..85e46869 --- /dev/null +++ b/src/visits/helpers/VisitsFilterDropdown.tsx @@ -0,0 +1,49 @@ +import { DropdownItem, DropdownItemProps } from 'reactstrap'; // eslint-disable-line import/named +import { OrphanVisitType } from '../types'; +import { DropdownBtn } from '../../utils/DropdownBtn'; +import { hasValue } from '../../utils/utils'; + +export interface VisitsFilter { + orphanVisitsType?: OrphanVisitType | undefined; + excludeBots?: boolean; +} + +interface VisitsFilterDropdownProps { + onChange: (filters: VisitsFilter) => void; + selected?: VisitsFilter; + className?: string; + isOrphanVisits: boolean; +} + +export const VisitsFilterDropdown = ( + { onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps, +) => { + const { orphanVisitsType, excludeBots = false } = selected; + const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({ + active: orphanVisitsType === type, + onClick: () => onChange({ ...selected, orphanVisitsType: type }), + }); + + return ( + + Bots: + onChange({ ...selected, excludeBots: !selected?.excludeBots })}> + Exclude potential bots + + + {isOrphanVisits && ( + <> + + + Orphan visits type: + Base URL + Invalid short URL + Regular 404 + + )} + + + onChange({})}>Clear filters + + ); +}; diff --git a/src/visits/types/helpers.ts b/src/visits/types/helpers.ts index d2691504..00d248d8 100644 --- a/src/visits/types/helpers.ts +++ b/src/visits/types/helpers.ts @@ -1,14 +1,8 @@ import { countBy, filter, groupBy, pipe, prop } from 'ramda'; import { normalizeVisits } from '../services/VisitsParser'; -import { - Visit, - OrphanVisit, - CreateVisit, - NormalizedVisit, - NormalizedOrphanVisit, - Stats, - OrphanVisitType, -} from './index'; +import { VisitsFilter } from '../helpers/VisitsFilterDropdown'; +import { hasValue } from '../../utils/utils'; +import { Visit, OrphanVisit, CreateVisit, NormalizedVisit, NormalizedOrphanVisit, Stats } from './index'; export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); @@ -35,7 +29,19 @@ export const highlightedVisitsToStats = ( property: HighlightableProps, ): Stats => countBy(prop(property) as any, highlightedVisits); -export const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe( +export const normalizeAndFilterVisits = (visits: Visit[], filters: VisitsFilter) => pipe( normalizeVisits, - filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type), + filter((normalizedVisit: NormalizedVisit) => { + if (!hasValue(filters)) { + return true; + } + + const { orphanVisitsType, excludeBots } = filters; + + if (orphanVisitsType && orphanVisitsType !== (normalizedVisit as NormalizedOrphanVisit).type) { + return false; + } + + return !(excludeBots && normalizedVisit.potentialBot); + }), )(visits); diff --git a/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx deleted file mode 100644 index c41b340a..00000000 --- a/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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); - }); -}); diff --git a/test/visits/helpers/VisitsFilterDropdown.test.tsx b/test/visits/helpers/VisitsFilterDropdown.test.tsx new file mode 100644 index 00000000..a947eb31 --- /dev/null +++ b/test/visits/helpers/VisitsFilterDropdown.test.tsx @@ -0,0 +1,71 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { OrphanVisitType } from '../../../src/visits/types'; +import { VisitsFilter, VisitsFilterDropdown } from '../../../src/visits/helpers/VisitsFilterDropdown'; + +describe('', () => { + let wrapper: ShallowWrapper; + const onChange = jest.fn(); + const createWrapper = (selected: VisitsFilter = {}, isOrphanVisits = true) => { + wrapper = shallow( + , + ); + + return wrapper; + }; + + beforeEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it('has expected text', () => { + const wrapper = createWrapper(); + + expect(wrapper.prop('text')).toEqual('Filters'); + }); + + it.each([ + [ false, 4, 1 ], + [ true, 9, 2 ], + ])('renders expected amount of items', (isOrphanVisits, expectedItemsAmount, expectedHeadersAmount) => { + const wrapper = createWrapper({}, isOrphanVisits); + const items = wrapper.find(DropdownItem); + const headers = items.filterWhere((item) => !!item.prop('header')); + + expect(items).toHaveLength(expectedItemsAmount); + expect(headers).toHaveLength(expectedHeadersAmount); + }); + + it.each([ + [ 'base_url' as OrphanVisitType, 4, 1 ], + [ 'invalid_short_url' as OrphanVisitType, 5, 1 ], + [ 'regular_404' as OrphanVisitType, 6, 1 ], + [ undefined, -1, 0 ], + ])('sets expected item as active', (orphanVisitsType, expectedSelectedIndex, expectedActiveItems) => { + const wrapper = createWrapper({ orphanVisitsType }); + 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([ + [ 1, { excludeBots: true }], + [ 4, { orphanVisitsType: 'base_url' }], + [ 5, { orphanVisitsType: 'invalid_short_url' }], + [ 6, { orphanVisitsType: 'regular_404' }], + [ 8, {}], + ])('invokes onChange with proper selection when an item is clicked', (index, expectedSelection) => { + const wrapper = createWrapper(); + const itemToClick = wrapper.find(DropdownItem).at(index); + + itemToClick.simulate('click'); + + expect(onChange).toHaveBeenCalledWith(expectedSelection); + }); +});