mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-17 13:03:50 +00:00
Extract shlink-web-component outside of src folder
This commit is contained in:
216
shlink-web-component/visits/VisitsTable.tsx
Normal file
216
shlink-web-component/visits/VisitsTable.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { min, splitEvery } from 'ramda';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { SimplePaginator } from '../../src/common/SimplePaginator';
|
||||
import { Time } from '../../src/utils/dates/Time';
|
||||
import { prettify } from '../../src/utils/helpers/numbers';
|
||||
import type { Order } from '../../src/utils/helpers/ordering';
|
||||
import { determineOrderDir, sortList } from '../../src/utils/helpers/ordering';
|
||||
import { SearchField } from '../../src/utils/SearchField';
|
||||
import { TableOrderIcon } from '../../src/utils/table/TableOrderIcon';
|
||||
import type { MediaMatcher } from '../../src/utils/types';
|
||||
import type { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||
import './VisitsTable.scss';
|
||||
|
||||
export interface VisitsTableProps {
|
||||
visits: NormalizedVisit[];
|
||||
selectedVisits?: NormalizedVisit[];
|
||||
setSelectedVisits: (visits: NormalizedVisit[]) => void;
|
||||
matchMedia?: MediaMatcher;
|
||||
isOrphanVisits?: boolean;
|
||||
}
|
||||
|
||||
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
|
||||
type VisitsOrder = Order<OrderableFields>;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
|
||||
`${browser} ${os} ${referer} ${country} ${city} ${(rest as NormalizedOrphanVisit).visitedUrl}`.toLowerCase().includes(
|
||||
searchTerm.toLowerCase(),
|
||||
);
|
||||
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
||||
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
||||
const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList<NormalizedVisit>(visits, order as any);
|
||||
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => {
|
||||
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [...allVisits];
|
||||
const sortedVisits = sortVisits(order, filteredVisits);
|
||||
const total = sortedVisits.length;
|
||||
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
|
||||
|
||||
return { visitsGroups, total };
|
||||
};
|
||||
|
||||
export const VisitsTable = ({
|
||||
visits,
|
||||
selectedVisits = [],
|
||||
setSelectedVisits,
|
||||
matchMedia = window.matchMedia,
|
||||
isOrphanVisits = false,
|
||||
}: VisitsTableProps) => {
|
||||
const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
|
||||
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
const [isMobileDevice, setIsMobileDevice] = useState(matchMobile());
|
||||
const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined);
|
||||
const [order, setOrder] = useState<VisitsOrder>({});
|
||||
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [searchTerm, order]);
|
||||
const isFirstLoad = useRef(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const end = page * PAGE_SIZE;
|
||||
const start = end - PAGE_SIZE;
|
||||
const fullSizeColSpan = 8 + Number(isOrphanVisits);
|
||||
|
||||
const orderByColumn = (field: OrderableFields) =>
|
||||
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
||||
const renderOrderIcon = (field: OrderableFields) =>
|
||||
<TableOrderIcon currentOrder={order} field={field} className="visits-table__header-icon" />;
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setIsMobileDevice(matchMobile());
|
||||
|
||||
window.addEventListener('resize', listener);
|
||||
|
||||
return () => window.removeEventListener('resize', listener);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
|
||||
!isFirstLoad.current && setSelectedVisits([]);
|
||||
isFirstLoad.current = false;
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="table-responsive-md">
|
||||
<table className="table table-bordered table-hover table-sm visits-table">
|
||||
<thead className="visits-table__header">
|
||||
<tr>
|
||||
<th
|
||||
className={`${headerCellsClass} text-center`}
|
||||
onClick={() => setSelectedVisits(
|
||||
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
|
||||
</th>
|
||||
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
|
||||
<FontAwesomeIcon icon={botIcon} />
|
||||
{renderOrderIcon('potentialBot')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('date')}>
|
||||
Date
|
||||
{renderOrderIcon('date')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('country')}>
|
||||
Country
|
||||
{renderOrderIcon('country')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('city')}>
|
||||
City
|
||||
{renderOrderIcon('city')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
|
||||
Browser
|
||||
{renderOrderIcon('browser')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('os')}>
|
||||
OS
|
||||
{renderOrderIcon('os')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
|
||||
Referrer
|
||||
{renderOrderIcon('referer')}
|
||||
</th>
|
||||
{isOrphanVisits && (
|
||||
<th className={headerCellsClass} onClick={orderByColumn('visitedUrl')}>
|
||||
Visited URL
|
||||
{renderOrderIcon('visitedUrl')}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={fullSizeColSpan} className="p-0">
|
||||
<SearchField noBorder large={false} onChange={setSearchTerm} />
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!resultSet.visitsGroups[page - 1]?.length && (
|
||||
<tr>
|
||||
<td colSpan={fullSizeColSpan} className="text-center">
|
||||
No visits found with current filtering
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{resultSet.visitsGroups[page - 1]?.map((visit, index) => {
|
||||
const isSelected = selectedVisits.includes(visit);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className={classNames({ 'table-active': isSelected })}
|
||||
onClick={() => setSelectedVisits(
|
||||
isSelected ? selectedVisits.filter((v) => v !== visit) : [...selectedVisits, visit],
|
||||
)}
|
||||
>
|
||||
<td className="text-center">
|
||||
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{visit.potentialBot && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
|
||||
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
|
||||
Potentially a visit from a bot or crawler
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td><Time date={visit.date} /></td>
|
||||
<td>{visit.country}</td>
|
||||
<td>{visit.city}</td>
|
||||
<td>{visit.browser}</td>
|
||||
<td>{visit.os}</td>
|
||||
<td>{visit.referer}</td>
|
||||
{isOrphanVisits && <td>{(visit as NormalizedOrphanVisit).visitedUrl}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{resultSet.total > PAGE_SIZE && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={fullSizeColSpan} className="visits-table__footer-cell visits-table__sticky">
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<SimplePaginator
|
||||
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)}
|
||||
currentPage={page}
|
||||
setCurrentPage={setPage}
|
||||
centered={isMobileDevice}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames('col-md-6', {
|
||||
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
|
||||
'text-center mt-3': isMobileDevice,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
Visits <b>{prettify(start + 1)}</b> to{' '}
|
||||
<b>{prettify(min(end, resultSet.total))}</b> of{' '}
|
||||
<b>{prettify(resultSet.total)}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user