web(test): routes/Events

This commit is contained in:
Paul Armstrong 2021-02-14 10:47:59 -08:00 committed by Blake Blackshear
parent f87813805a
commit fe59d90c51
4 changed files with 162 additions and 46 deletions

View File

@ -6,39 +6,52 @@ export function Table({ children, className = '' }) {
);
}
export function Thead({ children, className }) {
return <thead className={className}>{children}</thead>;
export function Thead({ children, className, ...attrs }) {
return (
<thead className={className} {...attrs}>
{children}
</thead>
);
}
export function Tbody({ children, className }) {
return <tbody className={className}>{children}</tbody>;
export function Tbody({ children, className, ...attrs }) {
return (
<tbody className={className} {...attrs}>
{children}
</tbody>
);
}
export function Tfoot({ children, className = '' }) {
return <tfoot className={`${className}`}>{children}</tfoot>;
export function Tfoot({ children, className = '', ...attrs }) {
return (
<tfoot className={`${className}`} {...attrs}>
{children}
</tfoot>
);
}
export function Tr({ children, className = '' }) {
export function Tr({ children, className = '', ...attrs }) {
return (
<tr
className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`}
{...attrs}
>
{children}
</tr>
);
}
export function Th({ children, className = '', colspan }) {
export function Th({ children, className = '', colspan, ...attrs }) {
return (
<th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan}>
<th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan} {...attrs}>
{children}
</th>
);
}
export function Td({ children, className = '', colspan }) {
export function Td({ children, className = '', colspan, ...attrs }) {
return (
<td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan}>
<td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
{children}
</td>
);

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
export function useResizeObserver(...refs) {
const [dimensions, setDimensions] = useState(
@ -28,3 +28,32 @@ export function useResizeObserver(...refs) {
return dimensions;
}
export function useIntersectionObserver() {
const [entry, setEntry] = useState({});
const [node, setNode] = useState(null);
const observer = useRef(null);
useEffect(() => {
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver((entries) => {
window.requestAnimationFrame(() => {
setEntry(entries[0]);
});
});
if (node) {
observer.current.observe(node);
}
return () => {
observer.current.disconnect();
};
}, [node]);
return [entry, setNode];
}

View File

@ -5,11 +5,12 @@ import Link from '../components/Link';
import Select from '../components/Select';
import produce from 'immer';
import { route } from 'preact-router';
import { useIntersectionObserver } from '../hooks';
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
import { useCallback, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/hooks';
const API_LIMIT = 25;
const API_LIMIT = 5;
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
const reducer = (state = initialState, action) => {
@ -43,69 +44,59 @@ const reducer = (state = initialState, action) => {
}
};
const defaultSearchString = `include_thumbnails=0&limit=${API_LIMIT}`;
const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`;
function removeDefaultSearchKeys(searchParams) {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
searchParams.delete('before');
}
export default function Events({ path: pathname } = {}) {
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
const apiHost = useApiHost();
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
const { searchParams: initialSearchParams } = new URL(window.location);
const [searchString, setSearchString] = useState(`${defaultSearchString}&${initialSearchParams.toString()}`);
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
const { data, status } = useEvents(searchString);
useEffect(() => {
if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
}
if (Array.isArray(data) && data.length < API_LIMIT) {
if (data && Array.isArray(data) && data.length < limit) {
dispatch({ type: 'REACHED_END', meta: { searchString } });
}
}, [data, searchString, searchStrings]);
}, [data, limit, searchString, searchStrings]);
const observer = useRef(
new IntersectionObserver((entries, observer) => {
window.requestAnimationFrame(() => {
if (entries.length === 0) {
return;
}
// under certain edge cases, a ref may be applied / in memory twice
// avoid fetching twice by grabbing the last observed entry only
const entry = entries[entries.length - 1];
if (entry.isIntersecting) {
const { startTime } = entry.target.dataset;
const { searchParams } = new URL(window.location);
searchParams.set('before', parseFloat(startTime) - 0.0001);
const [entry, setIntersectNode] = useIntersectionObserver();
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
}
});
})
);
useEffect(() => {
if (entry && entry.isIntersecting) {
const { startTime } = entry.target.dataset;
const { searchParams } = new URL(window.location);
searchParams.set('before', parseFloat(startTime) - 0.0001);
setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`);
}
}, [entry, limit]);
const lastCellRef = useCallback(
(node) => {
if (node !== null) {
observer.current.disconnect();
if (!reachedEnd) {
observer.current.observe(node);
}
if (node !== null && !reachedEnd) {
setIntersectNode(node);
}
},
[observer, reachedEnd]
[setIntersectNode, reachedEnd]
);
const handleFilter = useCallback(
(searchParams) => {
dispatch({ type: 'RESET' });
removeDefaultSearchKeys(searchParams);
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`);
route(`${pathname}?${searchParams.toString()}`);
},
[pathname, setSearchString]
[limit, pathname, setSearchString]
);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
@ -140,7 +131,7 @@ export default function Events({ path: pathname } = {}) {
const end = new Date(parseInt(endTime * 1000, 10));
const ref = i === events.length - 1 ? lastCellRef : undefined;
return (
<Tr key={id}>
<Tr data-testid={`event-${id}`} key={id}>
<Td className="w-40">
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
<img

View File

@ -0,0 +1,83 @@
import { h } from 'preact';
import * as Api from '../../api';
import * as Hooks from '../../hooks';
import Events from '../Events';
import { render, screen } from '@testing-library/preact';
describe('Events Route', () => {
let useEventsMock, useIntersectionMock;
beforeEach(() => {
useEventsMock = jest.spyOn(Api, 'useEvents').mockImplementation(() => ({
data: null,
status: 'loading',
}));
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: {
cameras: {
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, zones: [] },
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, zones: [] },
},
},
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://localhost:5000');
useIntersectionMock = jest.spyOn(Hooks, 'useIntersectionObserver').mockImplementation(() => [null, jest.fn()]);
});
test('shows an ActivityIndicator if not yet loaded', async () => {
render(<Events limit={5} path="/events" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test('does not show ActivityIndicator after loaded', async () => {
useEventsMock.mockReturnValue({ data: mockEvents, status: 'loaded' });
render(<Events limit={5} path="/events" />);
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
});
test('loads more when the intersectionObserver fires', async () => {
const setIntersectionNode = jest.fn();
useIntersectionMock.mockReturnValue([null, setIntersectionNode]);
useEventsMock.mockImplementation((searchString) => {
if (searchString.includes('before=')) {
const params = new URLSearchParams(searchString);
const before = parseFloat(params.get('before'));
const index = mockEvents.findIndex((el) => el.start_time === before + 0.0001);
return { data: mockEvents.slice(index, index + 5), status: 'loaded' };
}
return { data: mockEvents.slice(0, 5), status: 'loaded' };
});
const { rerender } = render(<Events limit={5} path="/events" />);
expect(setIntersectionNode).toHaveBeenCalled();
expect(useEventsMock).toHaveBeenCalledWith('include_thumbnails=0&limit=5&');
expect(screen.queryAllByTestId(/event-\d+/)).toHaveLength(5);
useIntersectionMock.mockReturnValue([
{
isIntersecting: true,
target: { dataset: { startTime: mockEvents[4].start_time } },
},
setIntersectionNode,
]);
rerender(<Events limit={5} path="/events" />);
expect(useEventsMock).toHaveBeenCalledWith(
`include_thumbnails=0&limit=5&before=${mockEvents[4].start_time - 0.0001}`
);
expect(screen.queryAllByTestId(/event-\d+/)).toHaveLength(10);
});
});
const mockEvents = new Array(12).fill(null).map((v, i) => ({
end_time: 1613257337 + i,
false_positive: false,
has_clip: true,
has_snapshot: true,
id: i,
label: 'person',
start_time: 1613257326 + i,
top_score: Math.random(),
zones: ['front_patio'],
thumbnail: '/9j/4aa...',
}));