mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
web(test): routes/Events
This commit is contained in:
parent
f87813805a
commit
fe59d90c51
@ -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>
|
||||
);
|
||||
|
@ -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];
|
||||
}
|
||||
|
@ -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
|
||||
|
83
web/src/routes/__tests__/Events.test.jsx
Normal file
83
web/src/routes/__tests__/Events.test.jsx
Normal 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...',
|
||||
}));
|
Loading…
Reference in New Issue
Block a user