mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Improved dropdown to filter visits, adding support to filter out bots
This commit is contained in:
parent
a0ab9533cb
commit
638ce89780
7 changed files with 151 additions and 110 deletions
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,10 +20,10 @@ 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 +85,7 @@ 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 buildSectionUrl = (subPath?: string) => {
|
const buildSectionUrl = (subPath?: string) => {
|
||||||
const query = domain ? `?domain=${domain}` : '';
|
const query = domain ? `?domain=${domain}` : '';
|
||||||
|
@ -93,10 +93,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 +279,12 @@ 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"
|
selected={visitsFilter}
|
||||||
selected={orphanVisitType}
|
onChange={setVisitsFilter}
|
||||||
onChange={setOrphanVisitType}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{visits.length > 0 && (
|
{visits.length > 0 && (
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
49
src/visits/helpers/VisitsFilterDropdown.tsx
Normal file
49
src/visits/helpers/VisitsFilterDropdown.tsx
Normal file
|
@ -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 (
|
||||||
|
<DropdownBtn text="Filters" dropdownClassName={className} className="mr-3" right minWidth={250}>
|
||||||
|
<DropdownItem header>Bots:</DropdownItem>
|
||||||
|
<DropdownItem active={excludeBots} onClick={() => onChange({ ...selected, excludeBots: !selected?.excludeBots })}>
|
||||||
|
Exclude potential bots
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
{isOrphanVisits && (
|
||||||
|
<>
|
||||||
|
<DropdownItem divider />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
71
test/visits/helpers/VisitsFilterDropdown.test.tsx
Normal file
71
test/visits/helpers/VisitsFilterDropdown.test.tsx
Normal file
|
@ -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('<VisitsFilterDropdown />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const createWrapper = (selected: VisitsFilter = {}, isOrphanVisits = true) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<VisitsFilterDropdown isOrphanVisits={isOrphanVisits} 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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue