From 638ce8978030f47f3ef259c055455df388030aab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 20:34:28 +0200 Subject: [PATCH 1/4] Improved dropdown to filter visits, adding support to filter out bots --- src/utils/DropdownBtn.tsx | 6 +- src/visits/VisitsStats.tsx | 25 +++---- .../helpers/OrphanVisitTypeDropdown.tsx | 26 ------- src/visits/helpers/VisitsFilterDropdown.tsx | 49 +++++++++++++ src/visits/types/helpers.ts | 28 +++++--- .../helpers/OrphanVisitTypeDropdown.test.tsx | 56 --------------- .../helpers/VisitsFilterDropdown.test.tsx | 71 +++++++++++++++++++ 7 files changed, 151 insertions(+), 110 deletions(-) delete mode 100644 src/visits/helpers/OrphanVisitTypeDropdown.tsx create mode 100644 src/visits/helpers/VisitsFilterDropdown.tsx delete mode 100644 test/visits/helpers/OrphanVisitTypeDropdown.test.tsx create mode 100644 test/visits/helpers/VisitsFilterDropdown.test.tsx 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); + }); +}); From affe2309b067f1baaf12f4f9f0287fedec8dd647 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 21:01:20 +0200 Subject: [PATCH 2/4] Ensured filter for bots does not show for Shlink older than 2.7.0 --- src/visits/VisitsStats.tsx | 3 +++ src/visits/helpers/VisitsFilterDropdown.tsx | 22 +++++++++++++------ test/short-urls/EditShortUrl.test.tsx | 2 +- test/short-urls/ShortUrlForm.test.tsx | 2 +- .../helpers/VisitsFilterDropdown.test.tsx | 20 ++++++++++++++++- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index f0cad255..feb354f1 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -16,6 +16,7 @@ import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { Settings } from '../settings/reducers/settings'; import { SelectedServer } from '../servers/data'; +import { supportsBotVisits } from '../utils/helpers/features'; import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; @@ -86,6 +87,7 @@ const VisitsStats: FC = ({ const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); const [ visitsFilter, setVisitsFilter ] = useState({}); + const botsSupported = supportsBotVisits(selectedServer); const buildSectionUrl = (subPath?: string) => { const query = domain ? `?domain=${domain}` : ''; @@ -282,6 +284,7 @@ const VisitsStats: FC = ({ diff --git a/src/visits/helpers/VisitsFilterDropdown.tsx b/src/visits/helpers/VisitsFilterDropdown.tsx index 85e46869..6cbdcdc9 100644 --- a/src/visits/helpers/VisitsFilterDropdown.tsx +++ b/src/visits/helpers/VisitsFilterDropdown.tsx @@ -13,28 +13,36 @@ interface VisitsFilterDropdownProps { selected?: VisitsFilter; className?: string; isOrphanVisits: boolean; + botsSupported: boolean; } export const VisitsFilterDropdown = ( - { onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps, + { onChange, selected = {}, className, isOrphanVisits, botsSupported }: VisitsFilterDropdownProps, ) => { + if (!botsSupported && !isOrphanVisits) { + return null; + } + const { orphanVisitsType, excludeBots = false } = selected; const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({ active: orphanVisitsType === type, onClick: () => onChange({ ...selected, orphanVisitsType: type }), }); + const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); return ( - Bots: - onChange({ ...selected, excludeBots: !selected?.excludeBots })}> - Exclude potential bots - + {botsSupported && ( + <> + Bots: + Exclude potential bots + + )} + + {botsSupported && isOrphanVisits && } {isOrphanVisits && ( <> - - Orphan visits type: Base URL Invalid short URL diff --git a/test/short-urls/EditShortUrl.test.tsx b/test/short-urls/EditShortUrl.test.tsx index 2522cfee..51243bf7 100644 --- a/test/short-urls/EditShortUrl.test.tsx +++ b/test/short-urls/EditShortUrl.test.tsx @@ -14,7 +14,7 @@ describe('', () => { const ShortUrlForm = () => null; const goBack = jest.fn(); const getShortUrlDetail = jest.fn(); - const editShortUrl = jest.fn(); + const editShortUrl = jest.fn(async () => Promise.resolve()); const shortUrlCreation = { validateUrls: true }; const createWrapper = (detail: Partial = {}, edition: Partial = {}) => { const EditSHortUrl = createEditShortUrl(ShortUrlForm); diff --git a/test/short-urls/ShortUrlForm.test.tsx b/test/short-urls/ShortUrlForm.test.tsx index b5b9dc6d..7fca5360 100644 --- a/test/short-urls/ShortUrlForm.test.tsx +++ b/test/short-urls/ShortUrlForm.test.tsx @@ -12,7 +12,7 @@ import { SimpleCard } from '../../src/utils/SimpleCard'; describe('', () => { let wrapper: ShallowWrapper; const TagsSelector = () => null; - const createShortUrl = jest.fn(); + const createShortUrl = jest.fn(async () => Promise.resolve()); const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => { const ShortUrlForm = createShortUrlForm(TagsSelector, () => null); diff --git a/test/visits/helpers/VisitsFilterDropdown.test.tsx b/test/visits/helpers/VisitsFilterDropdown.test.tsx index a947eb31..9476ac9c 100644 --- a/test/visits/helpers/VisitsFilterDropdown.test.tsx +++ b/test/visits/helpers/VisitsFilterDropdown.test.tsx @@ -8,7 +8,12 @@ describe('', () => { const onChange = jest.fn(); const createWrapper = (selected: VisitsFilter = {}, isOrphanVisits = true) => { wrapper = shallow( - , + , ); return wrapper; @@ -68,4 +73,17 @@ describe('', () => { expect(onChange).toHaveBeenCalledWith(expectedSelection); }); + + it('does not render the component when neither orphan visits or bots filtering will be displayed', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.text()).toEqual(''); + }); }); From c4ed838510a07e310cacd31a59aceac121bcdba3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 21:06:29 +0200 Subject: [PATCH 3/4] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a096c278..522ddf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a a `conf.d` folder. * [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7. +* [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7. ### Changed * *Nothing* From 5bd57e71fd8551e5805cf56e9ff82a54867ff417 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 22 Jun 2021 21:12:06 +0200 Subject: [PATCH 4/4] Improved DropdownBtn test --- test/utils/DropdownBtn.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/utils/DropdownBtn.test.tsx b/test/utils/DropdownBtn.test.tsx index 2076f6ec..a8ee2012 100644 --- a/test/utils/DropdownBtn.test.tsx +++ b/test/utils/DropdownBtn.test.tsx @@ -38,4 +38,15 @@ describe('', () => { expect(toggle.prop('className')?.trim()).toEqual(expectedClasses); }); + + it.each([ + [ 100, { minWidth: '100px' }], + [ 250, { minWidth: '250px' }], + [ undefined, {}], + ])('renders proper styles when minWidth is provided', (minWidth, expectedStyle) => { + const wrapper = createWrapper({ text: '', minWidth }); + const style = wrapper.find(DropdownMenu).prop('style'); + + expect(style).toEqual(expectedStyle); + }); });