refactor: add confirmation dialogs for destructive actions

This commit is contained in:
Carlos Valente
2026-03-25 21:34:22 +01:00
committed by Carlos Valente
parent 3838bdc54d
commit 2d9909577e
3 changed files with 103 additions and 31 deletions
@@ -1,5 +1,10 @@
import { CustomViewSummary } from 'ontime-types'; import { CustomViewSummary } from 'ontime-types';
import { useState } from 'react';
import { deleteCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CustomViewsListItem from './CustomViewsListItem'; import CustomViewsListItem from './CustomViewsListItem';
@@ -13,21 +18,71 @@ interface CustomViewsListProps {
} }
export default function CustomViewsList({ views, onOpenUpload, onMutate, onError }: CustomViewsListProps) { export default function CustomViewsList({ views, onOpenUpload, onMutate, onError }: CustomViewsListProps) {
const [targetSlug, setTargetSlug] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const openDelete = (slug: string) => {
setTargetSlug(slug);
};
const submitDelete = async () => {
if (!targetSlug) {
return;
}
try {
setIsDeleting(true);
await deleteCustomView(targetSlug);
onMutate();
} catch (error) {
onError(maybeAxiosError(error));
} finally {
setIsDeleting(false);
setTargetSlug(null);
}
};
return ( return (
<Panel.Table> <>
<thead> <Panel.Table>
<tr> <thead>
<th>Name</th> <tr>
<th>URL</th> <th>Name</th>
<th className={style.actionsHeader} /> <th>URL</th>
</tr> <th className={style.actionsHeader} />
</thead> </tr>
<tbody> </thead>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />} <tbody>
{views.map((view, index) => ( {views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
<CustomViewsListItem key={view.slug} slug={view.slug} index={index} onMutate={onMutate} onError={onError} /> {views.map((view, index) => (
))} <CustomViewsListItem
</tbody> key={view.slug}
</Panel.Table> slug={view.slug}
index={index}
onDelete={openDelete}
onError={onError}
/>
))}
</tbody>
</Panel.Table>
<Dialog
isOpen={targetSlug !== null}
onClose={() => setTargetSlug(null)}
title='Delete custom view'
showBackdrop
showCloseButton
bodyElements='You will permanently delete this file. Are you sure?'
footerElements={
<>
<Button size='large' onClick={() => setTargetSlug(null)}>
Cancel
</Button>
<Button variant='destructive' size='large' onClick={submitDelete} loading={isDeleting}>
Delete custom view
</Button>
</>
}
/>
</>
); );
} }
@@ -1,6 +1,6 @@
import { IoDownloadOutline, IoOpenOutline, IoTrash } from 'react-icons/io5'; import { IoDownloadOutline, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { deleteCustomView, downloadCustomView } from '../../../../common/api/customViews'; import { downloadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
@@ -12,11 +12,11 @@ import style from './CustomViews.module.scss';
interface CustomViewsListItemProps { interface CustomViewsListItemProps {
slug: string; slug: string;
index: number; index: number;
onMutate: () => void; onDelete: (slug: string) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
export default function CustomViewsListItem({ slug, index, onMutate, onError }: CustomViewsListItemProps) { export default function CustomViewsListItem({ slug, index, onDelete, onError }: CustomViewsListItemProps) {
const handlePreview = () => { const handlePreview = () => {
handleLinks(`external/${encodeURIComponent(slug)}/`); handleLinks(`external/${encodeURIComponent(slug)}/`);
}; };
@@ -29,15 +29,6 @@ export default function CustomViewsListItem({ slug, index, onMutate, onError }:
} }
}; };
const handleDelete = async () => {
try {
await deleteCustomView(slug);
onMutate();
} catch (error) {
onError(maybeAxiosError(error));
}
};
return ( return (
<tr> <tr>
<td>{slug}</td> <td>{slug}</td>
@@ -62,7 +53,7 @@ export default function CustomViewsListItem({ slug, index, onMutate, onError }:
</IconButton> </IconButton>
<IconButton <IconButton
variant='ghosted-destructive' variant='ghosted-destructive'
onClick={handleDelete} onClick={() => onDelete(slug)}
aria-label='Delete custom view' aria-label='Delete custom view'
data-testid={`custom-view__delete_${index}`} data-testid={`custom-view__delete_${index}`}
> >
@@ -16,7 +16,9 @@ import {
renameProject, renameProject,
} from '../../../../common/api/db'; } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -50,6 +52,7 @@ export default function ProjectListItem({
}: ProjectListItemProps) { }: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false);
const handleSubmitAction = (actionType: 'rename' | 'duplicate') => { const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
return async (values: ProjectFormValues) => { return async (values: ProjectFormValues) => {
@@ -99,6 +102,11 @@ export default function ProjectListItem({
} }
}; };
const submitDelete = async () => {
await handleDelete(filename);
setDeleteOpen(false);
};
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setSubmitError(null); setSubmitError(null);
onToggleEditMode(editMode, filename); onToggleEditMode(editMode, filename);
@@ -141,7 +149,7 @@ export default function ProjectListItem({
current={current} current={current}
filename={filename} filename={filename}
onChangeEditMode={handleToggleEditMode} onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete} onDelete={() => setDeleteOpen(true)}
onLoad={handleLoad} onLoad={handleLoad}
isDisabled={loading || showMergeForm} isDisabled={loading || showMergeForm}
onMerge={(filename) => handleToggleEditMode('merge', filename)} onMerge={(filename) => handleToggleEditMode('merge', filename)}
@@ -157,6 +165,24 @@ export default function ProjectListItem({
</td> </td>
</tr> </tr>
)} )}
<Dialog
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
title='Delete project'
showBackdrop
showCloseButton
bodyElements='You will permanently remove this project file. Are you sure?'
footerElements={
<>
<Button size='large' onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button variant='destructive' size='large' onClick={submitDelete} loading={loading}>
Delete project
</Button>
</>
}
/>
</> </>
); );
} }
@@ -166,7 +192,7 @@ interface ActionMenuProps {
filename: string; filename: string;
isDisabled: boolean; isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void; onChangeEditMode: (editMode: EditMode, filename: string) => void;
onDelete: (filename: string) => Promise<void>; onDelete: () => void;
onLoad: (filename: string) => Promise<void>; onLoad: (filename: string) => Promise<void>;
onMerge: (filename: string) => void; onMerge: (filename: string) => void;
} }
@@ -208,7 +234,7 @@ function ActionMenu(props: ActionMenuProps) {
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate }, { type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload }, { type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
{ type: 'divider' }, { type: 'divider' },
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: () => onDelete(filename), disabled: current }, { type: 'item', icon: IoTrash, label: 'Delete', onClick: onDelete, disabled: current },
]} ]}
> >
<IoEllipsisHorizontal /> <IoEllipsisHorizontal />