forked from Github/frigate
feat(web): Delete events from Event page and API (#991)
Co-authored-by: Scott Roach <scott@thinkpivot.io> Co-authored-by: Paul Armstrong <paul@spaceyak.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="z-0"></div>
|
||||
<div id="dialogs" class="z-0"></div>
|
||||
<div id="menus" class="z-0"></div>
|
||||
<div id="tooltips" class="z-0"></div>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
47
web/src/components/Dialog.jsx
Normal file
47
web/src/components/Dialog.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import Button from './Button';
|
||||
import Heading from './Heading';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function Dialog({ actions = [], portalRootID = 'dialogs', title, text }) {
|
||||
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setShow(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dialog = (
|
||||
<Fragment>
|
||||
<div
|
||||
data-testid="scrim"
|
||||
key="scrim"
|
||||
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||
>
|
||||
<div
|
||||
role="modal"
|
||||
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 max-w-sm text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||
show ? 'scale-100 opacity-100' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Heading size="lg">{title}</Heading>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||
{actions.map(({ color, text, onClick, ...props }, i) => (
|
||||
<Button className="ml-2" color={color} key={i} onClick={onClick} type="text" {...props}>
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
|
||||
}
|
||||
38
web/src/components/__tests__/Dialog.test.jsx
Normal file
38
web/src/components/__tests__/Dialog.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { h } from 'preact';
|
||||
import Dialog from '../Dialog';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Dialog', () => {
|
||||
let portal;
|
||||
|
||||
beforeAll(() => {
|
||||
portal = document.createElement('div');
|
||||
portal.id = 'dialogs';
|
||||
document.body.appendChild(portal);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
test('renders to a portal', async () => {
|
||||
render(<Dialog title="Tacos" text="This is the dialog" />);
|
||||
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders action buttons', async () => {
|
||||
const handleClick = jest.fn();
|
||||
render(
|
||||
<Dialog
|
||||
actions={[
|
||||
{ color: 'red', text: 'Delete' },
|
||||
{ text: 'Okay', onClick: handleClick },
|
||||
]}
|
||||
title="Tacos"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
13
web/src/icons/Delete.jsx
Normal file
13
web/src/icons/Delete.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Delete({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M6 21h12V7H6v14zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Delete);
|
||||
@@ -1,5 +1,10 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
import { route } from 'preact-router';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Button from '../components/Button';
|
||||
import Delete from '../icons/Delete'
|
||||
import Dialog from '../components/Dialog';
|
||||
import Heading from '../components/Heading';
|
||||
import Link from '../components/Link';
|
||||
import { FetchStatus, useApiHost, useEvent } from '../api';
|
||||
@@ -8,9 +13,39 @@ import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
||||
export default function Event({ eventId }) {
|
||||
const apiHost = useApiHost();
|
||||
const { data, status } = useEvent(eventId);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
|
||||
|
||||
const handleClickDelete = () => {
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleDismissDeleteDialog = () => {
|
||||
setShowDialog(false);
|
||||
};
|
||||
|
||||
|
||||
const handleClickDeleteDialog = useCallback(async () => {
|
||||
|
||||
let success;
|
||||
try {
|
||||
const response = await fetch(`${apiHost}/api/events/${eventId}`, { method: 'DELETE' });
|
||||
success = await (response.status < 300 ? response.json() : { success: true });
|
||||
setDeleteStatus(success ? FetchStatus.LOADED : FetchStatus.ERROR);
|
||||
} catch (e) {
|
||||
setDeleteStatus(FetchStatus.ERROR);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setDeleteStatus(FetchStatus.LOADED);
|
||||
setShowDialog(false);
|
||||
route('/events', true);
|
||||
|
||||
}
|
||||
}, [apiHost, eventId, setShowDialog]);
|
||||
|
||||
if (status !== FetchStatus.LOADED) {
|
||||
return <ActivityIndicator />;
|
||||
return <ActivityIndicator />
|
||||
}
|
||||
|
||||
const startime = new Date(data.start_time * 1000);
|
||||
@@ -18,9 +53,27 @@ export default function Event({ eventId }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Heading>
|
||||
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
||||
</Heading>
|
||||
<div className="flex">
|
||||
<Heading className="flex-grow">
|
||||
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
||||
</Heading>
|
||||
<Button className="self-start" color="red" onClick={handleClickDelete}>
|
||||
<Delete className="w-6" /> Delete event
|
||||
</Button>
|
||||
{showDialog ? (
|
||||
<Dialog
|
||||
onDismiss={handleDismissDeleteDialog}
|
||||
title="Delete Event?"
|
||||
text="This event will be permanently deleted along with any related clips and snapshots"
|
||||
actions={[
|
||||
deleteStatus !== FetchStatus.LOADING
|
||||
? { text: 'Delete', color: 'red', onClick: handleClickDeleteDialog }
|
||||
: { text: 'Deleting…', color: 'red', disabled: true },
|
||||
{ text: 'Cancel', onClick: handleDismissDeleteDialog },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Table class="w-full">
|
||||
<Thead>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from 'preact';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Button from '../components/Button';
|
||||
import Dialog from '../components/Dialog';
|
||||
import Heading from '../components/Heading';
|
||||
import Select from '../components/Select';
|
||||
import Switch from '../components/Switch';
|
||||
@@ -10,6 +11,7 @@ import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export default function StyleGuide() {
|
||||
const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false });
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
const handleSwitch = useCallback(
|
||||
(id, checked) => {
|
||||
@@ -18,6 +20,10 @@ export default function StyleGuide() {
|
||||
[switches]
|
||||
);
|
||||
|
||||
const handleDismissDialog = () => {
|
||||
setShowDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading size="md">Button</Heading>
|
||||
@@ -59,6 +65,26 @@ export default function StyleGuide() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Heading size="md">Dialog</Heading>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDialog(true);
|
||||
}}
|
||||
>
|
||||
Show Dialog
|
||||
</Button>
|
||||
{showDialog ? (
|
||||
<Dialog
|
||||
onDismiss={handleDismissDialog}
|
||||
title="This is a dialog"
|
||||
text="Would you like to see more?"
|
||||
actions={[
|
||||
{ text: 'Yes', color: 'red', onClick: handleDismissDialog },
|
||||
{ text: 'No', onClick: handleDismissDialog },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Heading size="md">Switch</Heading>
|
||||
<div className="flex-col space-y-4 max-w-4xl">
|
||||
<Switch label="Disabled, off" labelPosition="after" />
|
||||
|
||||
Reference in New Issue
Block a user