Merge pull request #443 from acelaya-forks/feature/visits-filtering

Feature/visits filtering
This commit is contained in:
Alejandro Celaya
2021-06-22 21:17:35 +02:00
committed by GitHub
11 changed files with 194 additions and 112 deletions

View File

@@ -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. * [#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. * [#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 ### Changed
* *Nothing* * *Nothing*

View File

@@ -9,18 +9,20 @@ export interface DropdownBtnProps {
className?: string; className?: string;
dropdownClassName?: string; dropdownClassName?: string;
right?: boolean; right?: boolean;
minWidth?: number;
} }
export const DropdownBtn: FC<DropdownBtnProps> = ( export const DropdownBtn: FC<DropdownBtnProps> = (
{ text, disabled = false, className = '', children, dropdownClassName, right = false }, { text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth },
) => { ) => {
const [ isOpen, toggle ] = useToggle(); const [ isOpen, toggle ] = useToggle();
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
const style = { minWidth: minWidth && `${minWidth}px` };
return ( return (
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}> <Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle> <DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
<DropdownMenu className="w-100" right={right}>{children}</DropdownMenu> <DropdownMenu className="w-100" right={right} style={style}>{children}</DropdownMenu>
</Dropdown> </Dropdown>
); );
}; };

View File

@@ -16,14 +16,15 @@ import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features';
import SortableBarGraph from './helpers/SortableBarGraph'; import SortableBarGraph from './helpers/SortableBarGraph';
import GraphCard from './helpers/GraphCard'; import GraphCard from './helpers/GraphCard';
import LineChartCard from './helpers/LineChartCard'; import LineChartCard from './helpers/LineChartCard';
import VisitsTable from './VisitsTable'; import VisitsTable from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types'; import { NormalizedOrphanVisit, NormalizedVisit, VisitsInfo } from './types';
import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import OpenMapModalBtn from './helpers/OpenMapModalBtn';
import { processStatsFromVisits } from './services/VisitsParser'; import { processStatsFromVisits } from './services/VisitsParser';
import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; import { VisitsFilter, VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers';
import './VisitsStats.scss'; import './VisitsStats.scss';
@@ -85,7 +86,8 @@ const VisitsStats: FC<VisitsStatsProps> = ({
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval)); const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]); const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>(); const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
const [ orphanVisitType, setOrphanVisitType ] = useState<OrphanVisitType | undefined>(); const [ visitsFilter, setVisitsFilter ] = useState<VisitsFilter>({});
const botsSupported = supportsBotVisits(selectedServer);
const buildSectionUrl = (subPath?: string) => { const buildSectionUrl = (subPath?: string) => {
const query = domain ? `?domain=${domain}` : ''; const query = domain ? `?domain=${domain}` : '';
@@ -93,10 +95,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
}; };
const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo;
const normalizedVisits = useMemo( const normalizedVisits = useMemo(() => normalizeAndFilterVisits(visits, visitsFilter), [ visits, visitsFilter ]);
() => normalizeAndFilterVisits(visits, orphanVisitType),
[ visits, orphanVisitType ],
);
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
() => processStatsFromVisits(normalizedVisits), () => processStatsFromVisits(normalizedVisits),
[ normalizedVisits ], [ normalizedVisits ],
@@ -282,14 +281,13 @@ const VisitsStats: FC<VisitsStatsProps> = ({
onDatesChange={setDateRange} onDatesChange={setDateRange}
/> />
</div> </div>
{isOrphanVisits && ( <VisitsFilterDropdown
<OrphanVisitTypeDropdown className="ml-0 ml-md-2 mt-3 mt-md-0"
text="Filter by type" isOrphanVisits={isOrphanVisits}
className="ml-0 ml-md-2 mt-3 mt-md-0" botsSupported={botsSupported}
selected={orphanVisitType} selected={visitsFilter}
onChange={setOrphanVisitType} onChange={setVisitsFilter}
/> />
)}
</div> </div>
</div> </div>
{visits.length > 0 && ( {visits.length > 0 && (

View File

@@ -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) => (
<DropdownBtn text={text} dropdownClassName={className} className="mr-3" right>
<DropdownItem active={selected === 'base_url'} onClick={() => onChange('base_url')}>
Base URL
</DropdownItem>
<DropdownItem active={selected === 'invalid_short_url'} onClick={() => onChange('invalid_short_url')}>
Invalid short URL
</DropdownItem>
<DropdownItem active={selected === 'regular_404'} onClick={() => onChange('regular_404')}>
Regular 404
</DropdownItem>
<DropdownItem divider />
<DropdownItem onClick={() => onChange(undefined)}><i>Clear selection</i></DropdownItem>
</DropdownBtn>
);

View File

@@ -0,0 +1,57 @@
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;
botsSupported: boolean;
}
export const VisitsFilterDropdown = (
{ 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 (
<DropdownBtn text="Filters" dropdownClassName={className} className="mr-3" right minWidth={250}>
{botsSupported && (
<>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
</>
)}
{botsSupported && isOrphanVisits && <DropdownItem divider />}
{isOrphanVisits && (
<>
<DropdownItem header>Orphan visits type:</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('regular_404')}>Regular 404</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem disabled={!hasValue(selected)} onClick={() => onChange({})}><i>Clear filters</i></DropdownItem>
</DropdownBtn>
);
};

View File

@@ -1,14 +1,8 @@
import { countBy, filter, groupBy, pipe, prop } from 'ramda'; import { countBy, filter, groupBy, pipe, prop } from 'ramda';
import { normalizeVisits } from '../services/VisitsParser'; import { normalizeVisits } from '../services/VisitsParser';
import { import { VisitsFilter } from '../helpers/VisitsFilterDropdown';
Visit, import { hasValue } from '../../utils/utils';
OrphanVisit, import { Visit, OrphanVisit, CreateVisit, NormalizedVisit, NormalizedOrphanVisit, Stats } from './index';
CreateVisit,
NormalizedVisit,
NormalizedOrphanVisit,
Stats,
OrphanVisitType,
} from './index';
export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl');
@@ -35,7 +29,19 @@ export const highlightedVisitsToStats = <T extends NormalizedVisit>(
property: HighlightableProps<T>, property: HighlightableProps<T>,
): Stats => countBy(prop(property) as any, highlightedVisits); ): 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, 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); )(visits);

View File

@@ -14,7 +14,7 @@ describe('<EditShortUrl />', () => {
const ShortUrlForm = () => null; const ShortUrlForm = () => null;
const goBack = jest.fn(); const goBack = jest.fn();
const getShortUrlDetail = jest.fn(); const getShortUrlDetail = jest.fn();
const editShortUrl = jest.fn(); const editShortUrl = jest.fn(async () => Promise.resolve());
const shortUrlCreation = { validateUrls: true }; const shortUrlCreation = { validateUrls: true };
const createWrapper = (detail: Partial<ShortUrlDetail> = {}, edition: Partial<ShortUrlEdition> = {}) => { const createWrapper = (detail: Partial<ShortUrlDetail> = {}, edition: Partial<ShortUrlEdition> = {}) => {
const EditSHortUrl = createEditShortUrl(ShortUrlForm); const EditSHortUrl = createEditShortUrl(ShortUrlForm);

View File

@@ -12,7 +12,7 @@ import { SimpleCard } from '../../src/utils/SimpleCard';
describe('<ShortUrlForm />', () => { describe('<ShortUrlForm />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const TagsSelector = () => null; const TagsSelector = () => null;
const createShortUrl = jest.fn(); const createShortUrl = jest.fn(async () => Promise.resolve());
const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => { const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => {
const ShortUrlForm = createShortUrlForm(TagsSelector, () => null); const ShortUrlForm = createShortUrlForm(TagsSelector, () => null);

View File

@@ -38,4 +38,15 @@ describe('<DropdownBtn />', () => {
expect(toggle.prop('className')?.trim()).toEqual(expectedClasses); 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);
});
}); });

View File

@@ -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('<OrphanVisitTypeDropdown />', () => {
let wrapper: ShallowWrapper;
const onChange = jest.fn();
const createWrapper = (selected?: OrphanVisitType) => {
wrapper = shallow(<OrphanVisitTypeDropdown text="The text" selected={selected} onChange={onChange} />);
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);
});
});

View File

@@ -0,0 +1,89 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { DropdownItem } from 'reactstrap';
import { OrphanVisitType } from '../../../src/visits/types';
import { VisitsFilter, VisitsFilterDropdown } from '../../../src/visits/helpers/VisitsFilterDropdown';
describe('<VisitsFilterDropdown />', () => {
let wrapper: ShallowWrapper;
const onChange = jest.fn();
const createWrapper = (selected: VisitsFilter = {}, isOrphanVisits = true) => {
wrapper = shallow(
<VisitsFilterDropdown
isOrphanVisits={isOrphanVisits}
botsSupported={true}
selected={selected}
onChange={onChange}
/>,
);
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);
});
it('does not render the component when neither orphan visits or bots filtering will be displayed', () => {
const wrapper = shallow(
<VisitsFilterDropdown
isOrphanVisits={false}
botsSupported={false}
selected={{}}
onChange={onChange}
/>,
);
expect(wrapper.text()).toEqual('');
});
});