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 { 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 CustomViewsListItem from './CustomViewsListItem';
@@ -13,21 +18,71 @@ interface 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 (
<Panel.Table>
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th className={style.actionsHeader} />
</tr>
</thead>
<tbody>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
{views.map((view, index) => (
<CustomViewsListItem key={view.slug} slug={view.slug} index={index} onMutate={onMutate} onError={onError} />
))}
</tbody>
</Panel.Table>
<>
<Panel.Table>
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th className={style.actionsHeader} />
</tr>
</thead>
<tbody>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
{views.map((view, index) => (
<CustomViewsListItem
key={view.slug}
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 { deleteCustomView, downloadCustomView } from '../../../../common/api/customViews';
import { downloadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import IconButton from '../../../../common/components/buttons/IconButton';
import { handleLinks } from '../../../../common/utils/linkUtils';
@@ -12,11 +12,11 @@ import style from './CustomViews.module.scss';
interface CustomViewsListItemProps {
slug: string;
index: number;
onMutate: () => void;
onDelete: (slug: 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 = () => {
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 (
<tr>
<td>{slug}</td>
@@ -62,7 +53,7 @@ export default function CustomViewsListItem({ slug, index, onMutate, onError }:
</IconButton>
<IconButton
variant='ghosted-destructive'
onClick={handleDelete}
onClick={() => onDelete(slug)}
aria-label='Delete custom view'
data-testid={`custom-view__delete_${index}`}
>
@@ -16,7 +16,9 @@ import {
renameProject,
} from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -50,6 +52,7 @@ export default function ProjectListItem({
}: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false);
const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
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) => {
setSubmitError(null);
onToggleEditMode(editMode, filename);
@@ -141,7 +149,7 @@ export default function ProjectListItem({
current={current}
filename={filename}
onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete}
onDelete={() => setDeleteOpen(true)}
onLoad={handleLoad}
isDisabled={loading || showMergeForm}
onMerge={(filename) => handleToggleEditMode('merge', filename)}
@@ -157,6 +165,24 @@ export default function ProjectListItem({
</td>
</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;
isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void;
onDelete: (filename: string) => Promise<void>;
onDelete: () => void;
onLoad: (filename: string) => Promise<void>;
onMerge: (filename: string) => void;
}
@@ -208,7 +234,7 @@ function ActionMenu(props: ActionMenuProps) {
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
{ type: 'divider' },
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: () => onDelete(filename), disabled: current },
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: onDelete, disabled: current },
]}
>
<IoEllipsisHorizontal />