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...',
+}));
|