Skip to content

Commit fa20278

Browse files
authored
add delete confirmations for jobs (#546)
* fix vitest hook mocks * add delete confirmations for jobs Deleting jobs is irreversible, but the UI previously dispatched both the single-job and bulk delete actions immediately. That made it too easy to remove the wrong jobs with one stray click, especially from the bulk selection toolbar. Add a shared confirmation dialog built on the existing Headless UI `Dialog` primitives and route only delete actions through it. The job detail page now confirms before deleting one job, and the job list now confirms before deleting the current selection while leaving retry and cancel unchanged. Raise modal z-index so the backdrop and panel consistently layer over the left navigation rail. This uses the app's existing dialog stack instead of adding a new UI library, keeps styling consistent, and adds focused test coverage for the single-job and bulk delete flows. Fixes #545.
1 parent 6c41bf3 commit fa20278

File tree

9 files changed

+420
-91
lines changed

9 files changed

+420
-91
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Workflow detail: truncate long workflow names in the header to prevent overflow and add a copy button for the full name. [PR #524](https://github.com/riverqueue/riverui/pull/524).
1515
- JSON viewer: sort keys alphabetically in rendered and copied output for object payloads. [PR #525](https://github.com/riverqueue/riverui/pull/525).
1616
- Job state sidebar: only highlight `Running` when the selected jobs state is actually running, even with retained search filters in the URL. [Fixes #526](https://github.com/riverqueue/riverui/issues/526). [PR #527](https://github.com/riverqueue/riverui/pull/527).
17+
- Job delete actions: require confirmation before deleting a single job or selected jobs in bulk. [Fixes #545](https://github.com/riverqueue/riverui/issues/545). [PR #546](https://github.com/riverqueue/riverui/pull/546).
1718

1819
## [v0.15.0] - 2026-02-26
1920

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
Description,
3+
Dialog,
4+
DialogBackdrop,
5+
DialogPanel,
6+
DialogTitle,
7+
} from "@headlessui/react";
8+
import { type ReactNode } from "react";
9+
10+
export type ConfirmationDialogProps = {
11+
cancelText?: string;
12+
confirmText: string;
13+
description: ReactNode;
14+
onClose: () => void;
15+
onConfirm: () => void;
16+
open: boolean;
17+
pending?: boolean;
18+
title: string;
19+
};
20+
21+
export default function ConfirmationDialog({
22+
cancelText = "Cancel",
23+
confirmText,
24+
description,
25+
onClose,
26+
onConfirm,
27+
open,
28+
pending = false,
29+
title,
30+
}: ConfirmationDialogProps) {
31+
if (!open) return null;
32+
33+
const handleClose = () => {
34+
if (!pending) {
35+
onClose();
36+
}
37+
};
38+
39+
return (
40+
<Dialog className="relative z-[60]" onClose={handleClose} open>
41+
<DialogBackdrop
42+
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
43+
transition
44+
/>
45+
46+
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
47+
<div className="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0">
48+
<DialogPanel
49+
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
50+
transition
51+
>
52+
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 dark:bg-gray-800">
53+
<DialogTitle
54+
as="h3"
55+
className="text-base font-semibold text-gray-900 dark:text-white"
56+
>
57+
{title}
58+
</DialogTitle>
59+
<Description
60+
as="div"
61+
className="mt-2 text-sm text-gray-600 dark:text-gray-400"
62+
>
63+
{description}
64+
</Description>
65+
</div>
66+
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
67+
<button
68+
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs enabled:hover:bg-red-500 disabled:opacity-50 sm:ml-3 sm:w-auto dark:bg-red-500 dark:shadow-none dark:enabled:hover:bg-red-400"
69+
disabled={pending}
70+
onClick={onConfirm}
71+
type="button"
72+
>
73+
{confirmText}
74+
</button>
75+
<button
76+
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 disabled:opacity-50 sm:mt-0 sm:w-auto dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
77+
data-autofocus
78+
disabled={pending}
79+
onClick={handleClose}
80+
type="button"
81+
>
82+
{cancelText}
83+
</button>
84+
</div>
85+
</DialogPanel>
86+
</div>
87+
</div>
88+
</Dialog>
89+
);
90+
}

src/components/JobDetail.test.tsx

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,75 @@
11
import { jobFactory } from "@test/factories/job";
2-
import { render } from "@testing-library/react";
3-
import { expect, test } from "vitest";
2+
import { act, render, screen, waitFor, within } from "@testing-library/react";
3+
import { userEvent } from "storybook/test";
4+
import { expect, test, vi } from "vitest";
45

56
import JobDetail from "./JobDetail";
67

7-
test("adds 1 + 2 to equal 3", () => {
8-
const job = jobFactory.build();
9-
const cancel = () => {};
10-
const deleteFn = () => {};
11-
const retry = () => {};
12-
const { getByTestId: _getTestById } = render(
13-
<JobDetail cancel={cancel} deleteFn={deleteFn} job={job} retry={retry} />,
8+
test("requires confirmation before deleting a job", async () => {
9+
const job = jobFactory.completed().build({ id: 123n });
10+
const deleteFn = vi.fn();
11+
const user = userEvent.setup();
12+
13+
render(
14+
<JobDetail
15+
cancel={vi.fn()}
16+
deleteFn={deleteFn}
17+
job={job}
18+
retry={vi.fn()}
19+
/>,
1420
);
15-
expect(3).toBe(3);
21+
22+
await act(async () => {
23+
await user.click(screen.getByRole("button", { name: /^delete$/i }));
24+
});
25+
26+
expect(deleteFn).not.toHaveBeenCalled();
27+
const dialog = await screen.findByRole("dialog", { name: "Delete job?" });
28+
expect(
29+
within(dialog).getByText(/This permanently deletes job/i),
30+
).toBeInTheDocument();
31+
expect(within(dialog).getByText("123")).toBeInTheDocument();
32+
33+
await act(async () => {
34+
await user.click(
35+
within(dialog).getByRole("button", { name: /delete job/i }),
36+
);
37+
});
38+
39+
await waitFor(() => {
40+
expect(deleteFn).toHaveBeenCalledTimes(1);
41+
expect(
42+
screen.queryByRole("dialog", { name: "Delete job?" }),
43+
).not.toBeInTheDocument();
44+
});
45+
});
46+
47+
test("cancels job delete confirmation", async () => {
48+
const job = jobFactory.completed().build();
49+
const deleteFn = vi.fn();
50+
const user = userEvent.setup();
51+
52+
render(
53+
<JobDetail
54+
cancel={vi.fn()}
55+
deleteFn={deleteFn}
56+
job={job}
57+
retry={vi.fn()}
58+
/>,
59+
);
60+
61+
await act(async () => {
62+
await user.click(screen.getByRole("button", { name: /^delete$/i }));
63+
});
64+
const dialog = await screen.findByRole("dialog", { name: "Delete job?" });
65+
await act(async () => {
66+
await user.click(within(dialog).getByRole("button", { name: /cancel/i }));
67+
});
68+
69+
await waitFor(() => {
70+
expect(deleteFn).not.toHaveBeenCalled();
71+
expect(
72+
screen.queryByRole("dialog", { name: "Delete job?" }),
73+
).not.toBeInTheDocument();
74+
});
1675
});

src/components/JobDetail.tsx

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Badge } from "@components/Badge";
22
import ButtonForGroup from "@components/ButtonForGroup";
3+
import ConfirmationDialog from "@components/ConfirmationDialog";
34
import JobAttempts from "@components/JobAttempts";
45
import JobTimeline from "@components/JobTimeline";
56
import JSONView from "@components/JSONView";
@@ -15,7 +16,7 @@ import { Job, JobWithKnownMetadata } from "@services/jobs";
1516
import { JobState } from "@services/types";
1617
import { Link } from "@tanstack/react-router";
1718
import { capitalize } from "@utils/string";
18-
import { FormEvent } from "react";
19+
import { FormEvent, useState } from "react";
1920

2021
type JobDetailProps = {
2122
cancel: () => void;
@@ -185,12 +186,19 @@ export default function JobDetail({
185186
}
186187

187188
function ActionButtons({ cancel, deleteFn, job, retry }: JobDetailProps) {
189+
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
190+
188191
// Can only delete jobs that aren't running:
189192
const deleteDisabled = job.state === JobState.Running;
190193

191194
const deleteJob = (event: FormEvent) => {
192195
event.preventDefault();
196+
setDeleteConfirmationOpen(true);
197+
};
198+
199+
const confirmDelete = () => {
193200
deleteFn();
201+
setDeleteConfirmationOpen(false);
194202
};
195203

196204
// Can only cancel jobs that aren't already finalized (completed, discarded, cancelled):
@@ -215,26 +223,42 @@ function ActionButtons({ cancel, deleteFn, job, retry }: JobDetailProps) {
215223
};
216224

217225
return (
218-
<span className="isolate inline-flex rounded-md shadow-xs">
219-
<ButtonForGroup
220-
disabled={retryDisabled}
221-
Icon={ArrowUturnLeftIcon}
222-
onClick={retryJob}
223-
text="Retry"
224-
/>
225-
<ButtonForGroup
226-
disabled={cancelDisabled}
227-
Icon={XCircleIcon}
228-
onClick={cancelJob}
229-
text="Cancel"
230-
/>
231-
<ButtonForGroup
232-
disabled={deleteDisabled}
233-
Icon={TrashIcon}
234-
onClick={deleteJob}
235-
text="Delete"
226+
<>
227+
<span className="isolate inline-flex rounded-md shadow-xs">
228+
<ButtonForGroup
229+
disabled={retryDisabled}
230+
Icon={ArrowUturnLeftIcon}
231+
onClick={retryJob}
232+
text="Retry"
233+
/>
234+
<ButtonForGroup
235+
disabled={cancelDisabled}
236+
Icon={XCircleIcon}
237+
onClick={cancelJob}
238+
text="Cancel"
239+
/>
240+
<ButtonForGroup
241+
disabled={deleteDisabled}
242+
Icon={TrashIcon}
243+
onClick={deleteJob}
244+
text="Delete"
245+
/>
246+
</span>
247+
<ConfirmationDialog
248+
confirmText="Delete job"
249+
description={
250+
<>
251+
This permanently deletes job{" "}
252+
<span className="font-mono">{job.id.toString()}</span>. This action
253+
cannot be undone.
254+
</>
255+
}
256+
onClose={() => setDeleteConfirmationOpen(false)}
257+
onConfirm={confirmDelete}
258+
open={deleteConfirmationOpen}
259+
title="Delete job?"
236260
/>
237-
</span>
261+
</>
238262
);
239263
}
240264

0 commit comments

Comments
 (0)