diff --git a/web/src/components/Table.jsx b/web/src/components/Table.jsx index 12e669cd9..a80f7a508 100644 --- a/web/src/components/Table.jsx +++ b/web/src/components/Table.jsx @@ -6,39 +6,52 @@ export function Table({ children, className = '' }) { ); } -export function Thead({ children, className }) { - return {children}; +export function Thead({ children, className, ...attrs }) { + return ( + + {children} + + ); } -export function Tbody({ children, className }) { - return {children}; +export function Tbody({ children, className, ...attrs }) { + return ( + + {children} + + ); } -export function Tfoot({ children, className = '' }) { - return {children}; +export function Tfoot({ children, className = '', ...attrs }) { + return ( + + {children} + + ); } -export function Tr({ children, className = '' }) { +export function Tr({ children, className = '', ...attrs }) { return ( {children} ); } -export function Th({ children, className = '', colspan }) { +export function Th({ children, className = '', colspan, ...attrs }) { return ( - + {children} ); } -export function Td({ children, className = '', colspan }) { +export function Td({ children, className = '', colspan, ...attrs }) { return ( - + {children} ); diff --git a/web/src/hooks/index.jsx b/web/src/hooks/index.jsx index 385313cba..79a9390af 100644 --- a/web/src/hooks/index.jsx +++ b/web/src/hooks/index.jsx @@ -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]; +} diff --git a/web/src/routes/Events.jsx b/web/src/routes/Events.jsx index 363672c7d..7af75bb51 100644 --- a/web/src/routes/Events.jsx +++ b/web/src/routes/Events.jsx @@ -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 ( - + { + 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(); + expect(screen.queryByLabelText('Loading…')).toBeInTheDocument(); + }); + + test('does not show ActivityIndicator after loaded', async () => { + useEventsMock.mockReturnValue({ data: mockEvents, status: 'loaded' }); + render(); + 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(); + 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(); + 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...', +}));