rearrange event route and splitted into several components

This commit is contained in:
Bernt Christian Egeland 2021-08-27 18:28:45 +02:00
parent 46fe06e779
commit a0de6b7a73
11 changed files with 483 additions and 327 deletions

View File

@ -1,326 +0,0 @@
import { h, Fragment } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import Link from '../components/Link';
import Select from '../components/Select';
import produce from 'immer';
import { route } from 'preact-router';
import Event from './Event';
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, useReducer, useState } from 'preact/hooks';
const API_LIMIT = 25;
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'DELETE_EVENT': {
const { deletedId } = action;
return produce(state, (draftState) => {
const idx = draftState.events.findIndex((e) => e.id === deletedId);
if (idx === -1) return state;
draftState.events.splice(idx, 1);
draftState.deleted++;
});
}
case 'APPEND_EVENTS': {
const {
meta: { searchString },
payload,
} = action;
return produce(state, (draftState) => {
draftState.searchStrings[searchString] = true;
draftState.events.push(...payload);
draftState.deleted = 0;
});
}
case 'REACHED_END': {
const {
meta: { searchString },
} = action;
return produce(state, (draftState) => {
draftState.reachedEnd = true;
draftState.searchStrings[searchString] = true;
});
}
case 'RESET':
return initialState;
default:
return state;
}
};
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, limit = API_LIMIT } = {}) {
const apiHost = useApiHost();
const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
const { searchParams: initialSearchParams } = new URL(window.location);
const [viewEvent, setViewEvent] = useState(null);
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
const { data, status, deletedId } = useEvents(searchString);
const scrollToRef = {};
useEffect(() => {
if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
}
if (data && Array.isArray(data) && data.length + deleted < limit) {
dispatch({ type: 'REACHED_END', meta: { searchString } });
}
if (deletedId) {
dispatch({ type: 'DELETE_EVENT', deletedId });
}
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
const [entry, setIntersectNode] = useIntersectionObserver();
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 && !reachedEnd) {
setIntersectNode(node);
}
},
[setIntersectNode, reachedEnd]
);
const handleFilter = useCallback(
(searchParams) => {
dispatch({ type: 'RESET' });
removeDefaultSearchKeys(searchParams);
setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`);
route(`${pathname}?${searchParams.toString()}`);
},
[limit, pathname, setSearchString]
);
const viewEventHandler = (id) => {
//Toggle event view
if (viewEvent === id) return setViewEvent(null);
//Set event id to be rendered.
setViewEvent(id);
};
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
return (
<div className="space-y-4 w-full">
<Heading>Events</Heading>
<Filters onChange={handleFilter} searchParams={searchParams} />
<div className="min-w-0 overflow-auto">
<Table className="min-w-full table-fixed">
<Thead>
<Tr>
<Th />
<Th>Camera</Th>
<Th>Label</Th>
<Th>Score</Th>
<Th>Zones</Th>
<Th>Date</Th>
<Th>Start</Th>
<Th>End</Th>
</Tr>
</Thead>
<Tbody>
{events.map(
({ camera, id, label, start_time: startTime, end_time: endTime, top_score: score, zones }, i) => {
const start = new Date(parseInt(startTime * 1000, 10));
const end = new Date(parseInt(endTime * 1000, 10));
const ref = i === events.length - 1 ? lastCellRef : undefined;
return (
<Fragment key={id}>
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
<Td className="w-40">
<a
onClick={() => viewEventHandler(id)}
ref={ref}
data-start-time={startTime}
data-reached-end={reachedEnd}
>
<img
ref={(el) => (scrollToRef[id] = el)}
width="150"
height="150"
className="cursor-pointer"
style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/>
</a>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="camera"
name={camera}
/>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="label"
name={label}
/>
</Td>
<Td>{(score * 100).toFixed(2)}%</Td>
<Td>
<ul>
{zones.map((zone) => (
<li>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchString}
paramName="zone"
name={zone}
/>
</li>
))}
</ul>
</Td>
<Td>{start.toLocaleDateString()}</Td>
<Td>{start.toLocaleTimeString()}</Td>
<Td>{end.toLocaleTimeString()}</Td>
</Tr>
{viewEvent === id ? (
<Tr className="border-b-1">
<Td colSpan="8">
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
</Td>
</Tr>
) : null}
</Fragment>
);
}
)}
</Tbody>
<Tfoot>
<Tr>
<Td className="text-center p-4" colSpan="8">
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
</Td>
</Tr>
</Tfoot>
</Table>
</div>
</div>
);
}
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
const href = useMemo(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
removeDefaultSearchKeys(params);
return `${pathname}?${params.toString()}`;
}, [searchParams, paramName, pathname, name]);
const handleClick = useCallback(
(event) => {
event.preventDefault();
route(href, true);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
onFilter(params);
},
[href, searchParams, onFilter, paramName, name]
);
return (
<Link href={href} onclick={handleClick}>
{name}
</Link>
);
}
function Filters({ onChange, searchParams }) {
const { data } = useConfig();
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
const zones = useMemo(
() =>
Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
[data]
);
const labels = useMemo(() => {
return Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
return memo;
}, data.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [data]);
return (
<div className="flex space-x-4">
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
</div>
);
}
function Filter({ onChange, searchParams, paramName, options }) {
const handleSelect = useCallback(
(key) => {
const newParams = new URLSearchParams(searchParams.toString());
if (key !== 'all') {
newParams.set(paramName, key);
} else {
newParams.delete(paramName);
}
onChange(newParams);
},
[searchParams, paramName, onChange]
);
const selectOptions = useMemo(() => ['all', ...options], [options]);
return (
<Select
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
onChange={handleSelect}
options={selectOptions}
selected={searchParams.get(paramName) || 'all'}
/>
);
}

View File

@ -0,0 +1,31 @@
import { h } from 'preact';
import Select from '../../../components/Select';
import { useCallback, useMemo } from 'preact/hooks';
const Filter = ({ onChange, searchParams, paramName, options }) => {
const handleSelect = useCallback(
(key) => {
const newParams = new URLSearchParams(searchParams.toString());
if (key !== 'all') {
newParams.set(paramName, key);
} else {
newParams.delete(paramName);
}
onChange(newParams);
},
[searchParams, paramName, onChange]
);
const selectOptions = useMemo(() => ['all', ...options], [options]);
return (
<Select
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
onChange={handleSelect}
options={selectOptions}
selected={searchParams.get(paramName) || 'all'}
/>
);
};
export default Filter;

View File

@ -0,0 +1,31 @@
import { h } from 'preact';
import { useCallback, useMemo } from 'preact/hooks';
import Link from '../../../components/Link';
import { route } from 'preact-router';
const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => {
const href = useMemo(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
removeDefaultSearchKeys(params);
return `${pathname}?${params.toString()}`;
}, [searchParams, paramName, pathname, name, removeDefaultSearchKeys]);
const handleClick = useCallback(
(event) => {
event.preventDefault();
route(href, true);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
onFilter(params);
},
[href, searchParams, onFilter, paramName, name]
);
return (
<Link href={href} onclick={handleClick}>
{name}
</Link>
);
};
export default Filterable;

View File

@ -0,0 +1,39 @@
import { h } from 'preact';
import Filter from './filter';
import { useConfig } from '../../../api';
import { useMemo } from 'preact/hooks';
const Filters = ({ onChange, searchParams }) => {
const { data } = useConfig();
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
const zones = useMemo(
() =>
Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
[data]
);
const labels = useMemo(() => {
return Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
return memo;
}, data.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [data]);
return (
<div className="flex space-x-4">
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
</div>
);
};
export default Filters;

View File

@ -0,0 +1,2 @@
export { default as TableHead } from './tableHead';
export { default as Filters } from './filters';

View File

@ -0,0 +1,18 @@
import { h } from 'preact';
import { Thead, Th, Tr } from '../../../components/Table';
const TableHead = () => (
<Thead>
<Tr>
<Th />
<Th>Camera</Th>
<Th>Label</Th>
<Th>Score</Th>
<Th>Zones</Th>
<Th>Date</Th>
<Th>Start</Th>
<Th>End</Th>
</Tr>
</Thead>
);
export default TableHead;

View File

@ -0,0 +1,186 @@
import { h, Fragment } from 'preact';
import { useCallback, useEffect, useState, useRef } from 'preact/hooks';
import { Tr, Td } from '../../../components/Table';
import Filterable from './filterable';
import Event from '../../Event';
import { useIntersectionObserver } from '../../../hooks';
import { memo } from 'preact/compat';
import { useSearchString } from '../hooks/useSearchString';
const EventsRow = memo(
(
{
id,
apiHost,
start_time: startTime,
end_time: endTime,
handleFilter,
reachedEnd,
scrollToRef,
pathname,
searchParams,
camera,
label,
score,
zones,
numberOfEvents,
limit,
idx,
},
i
) => {
const [viewEvent, setViewEvent] = useState(null);
const [searchString, setSearchString, removeDefaultSearchKeys] = useSearchString(limit);
// const ref = useRef();
const viewEventHandler = useCallback(
(id) => {
//Toggle event view
if (viewEvent === id) return setViewEvent(null);
//Set event id to be rendered.
setViewEvent(id);
},
[viewEvent]
);
// useEffect(() => {
// const checkIfClickedOutside = (e) => {
// // If the menu is open and the clicked target is not within the menu,
// // then close the menu
// if (viewEvent && ref.current && !ref.current.contains(e.target)) {
// setViewEvent(null);
// }
// };
// document.addEventListener('mousedown', checkIfClickedOutside);
// return () => {
// // Cleanup the event listener
// document.removeEventListener('mousedown', checkIfClickedOutside);
// };
// }, [setViewEvent, viewEvent]);
const [entry, setIntersectNode] = useIntersectionObserver();
const lastCellRef = useCallback(
(node) => {
if (node !== null && !reachedEnd) {
setIntersectNode(node);
}
},
[setIntersectNode, reachedEnd]
);
useEffect(() => {
if (entry && entry.isIntersecting) {
const { startTime } = entry.target.dataset;
const { searchParams } = new URL(window.location);
searchParams.set('before', parseFloat(startTime) - 0.0001);
setSearchString(limit, searchParams.toString());
}
}, [entry, limit]);
const start = new Date(parseInt(startTime * 1000, 10));
const end = new Date(parseInt(endTime * 1000, 10));
const ref = idx === numberOfEvents - 1 ? lastCellRef : undefined;
console.log('table row renders');
return (
<Fragment key={id}>
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
<Td className="w-40">
<a onClick={() => viewEventHandler(id)} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
<img
ref={(el) => (scrollToRef[id] = el)}
width="150"
height="150"
className="cursor-pointer"
style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/>
</a>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="camera"
name={camera}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="label"
name={label}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</Td>
<Td>{(score * 100).toFixed(2)}%</Td>
<Td>
<ul>
{zones.map((zone) => (
<li>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchString}
paramName="zone"
name={zone}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</li>
))}
</ul>
</Td>
<Td>{start.toLocaleDateString()}</Td>
<Td>{start.toLocaleTimeString()}</Td>
<Td>{end.toLocaleTimeString()}</Td>
</Tr>
{viewEvent === id ? (
<Tr className="border-b-1">
<Td colSpan="8">
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
</Td>
</Tr>
) : null}
</Fragment>
// <Fragment>
// <Tr>
// <Td className="w-40">
// <a
// onClick={() => viewEventHandler(id)}
// ref={lastCellRef}
// data-start-time={startTime}
// data-reached-end={reachedEnd}
// >
// <img
// ref={(el) => (scrollToRef[id] = el)}
// width="150"
// height="150"
// className="cursor-pointer"
// style="min-height: 48px; min-width: 48px;"
// src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
// />
// </a>
// </Td>
// {viewEvent === id ? (
// <span ref={ref}>
// <Tr className="border-b-1">
// <Td colSpan="8">
// <Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
// </Td>
// </Tr>
// </span>
// ) : null}
// </Tr>
// </Fragment>
);
}
);
export default EventsRow;

View File

@ -0,0 +1,29 @@
import { h } from 'preact';
import { useState, useCallback } from 'preact/hooks';
const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`;
export const useSearchString = (limit, searchParams) => {
const { searchParams: initialSearchParams } = new URL(window.location);
const _searchParams = searchParams ? searchParams : initialSearchParams.toString();
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${_searchParams}`);
const changeSearchString = useCallback(
(limit, searchString) => {
setSearchString(`${defaultSearchString(limit)}&${searchString}`);
},
[setSearchString, defaultSearchString]
);
const removeDefaultSearchKeys = useCallback(
(searchParams) => {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
searchParams.delete('before');
},
[searchParams]
);
return [searchString, changeSearchString, removeDefaultSearchKeys];
};

View File

@ -0,0 +1,99 @@
import { h, Fragment } from 'preact';
import ActivityIndicator from '../../components/ActivityIndicator';
import Heading from '../../components/Heading';
import { TableHead, Filters } from './components';
import { route } from 'preact-router';
import TableRow from './components/tableRow';
import { FetchStatus, useApiHost, useEvents } from '../../api';
import { Table, Tbody, Tfoot, Tr, Td } from '../../components/Table';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/hooks';
import { reducer, initialState } from './reducer';
import { useSearchString } from './hooks/useSearchString';
const API_LIMIT = 25;
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
const apiHost = useApiHost();
const [searchString, setSearchString, removeDefaultSearchKeys] = useSearchString(limit);
const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
const { data, status, deletedId } = useEvents(searchString);
const [counter, setCounter] = useState(0);
let scrollToRef = useMemo(() => Object, []);
useEffect(() => {
if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
}
if (data && Array.isArray(data) && data.length + deleted < limit) {
dispatch({ type: 'REACHED_END', meta: { searchString } });
}
if (deletedId) {
dispatch({ type: 'DELETE_EVENT', deletedId });
}
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
useEffect(() => {
setInterval(() => {
setCounter((prev) => prev + 1);
}, 1000);
}, []);
const handleFilter = useCallback(
(searchParams) => {
dispatch({ type: 'RESET' });
removeDefaultSearchKeys(searchParams);
setSearchString(limit, searchParams.toString());
route(`${pathname}?${searchParams.toString()}`);
},
[limit, pathname, setSearchString, removeDefaultSearchKeys]
);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
console.log('counter ' + counter);
return (
<div className="space-y-4 w-full">
<Heading>Events</Heading>
<Filters onChange={handleFilter} searchParams={searchParams} />
<div className="min-w-0 overflow-auto">
<Table className="min-w-full table-fixed">
<TableHead />
<Tbody>
{events.map(({ id, ...rest }, idx) => {
return (
<TableRow
key={id}
idx={idx}
numberOfEvents={events.length}
id={id}
apiHost={apiHost}
scrollToRef={scrollToRef}
pathname={pathname}
searchParams={searchParams}
limit={API_LIMIT}
setSearchString={setSearchString}
handleFilter={handleFilter}
{...rest}
/>
);
})}
</Tbody>
<Tfoot>
<Tr>
<Td className="text-center p-4" colSpan="8">
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
</Td>
</Tr>
</Tfoot>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
import produce from 'immer';
export const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
export const reducer = (state = initialState, action) => {
switch (action.type) {
case 'DELETE_EVENT': {
const { deletedId } = action;
return produce(state, (draftState) => {
const idx = draftState.events.findIndex((e) => e.id === deletedId);
if (idx === -1) return state;
draftState.events.splice(idx, 1);
draftState.deleted++;
});
}
case 'APPEND_EVENTS': {
const {
meta: { searchString },
payload,
} = action;
return produce(state, (draftState) => {
draftState.searchStrings[searchString] = true;
draftState.events.push(...payload);
draftState.deleted = 0;
});
}
case 'REACHED_END': {
const {
meta: { searchString },
} = action;
return produce(state, (draftState) => {
draftState.reachedEnd = true;
draftState.searchStrings[searchString] = true;
});
}
case 'RESET':
return initialState;
default:
return state;
}
};

View File

@ -19,7 +19,7 @@ export async function getBirdseye(url, cb, props) {
} }
export async function getEvents(url, cb, props) { export async function getEvents(url, cb, props) {
const module = await import('./Events.jsx'); const module = await import('./Events');
return module.default; return module.default;
} }