Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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).
- JSON viewer: sort keys alphabetically in rendered and copied output for object payloads. [PR #525](https://github.com/riverqueue/riverui/pull/525).
- 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).
- 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).

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

Expand Down
90 changes: 90 additions & 0 deletions src/components/ConfirmationDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from "@headlessui/react";
import { type ReactNode } from "react";

export type ConfirmationDialogProps = {
cancelText?: string;
confirmText: string;
description: ReactNode;
onClose: () => void;
onConfirm: () => void;
open: boolean;
pending?: boolean;
title: string;
};

export default function ConfirmationDialog({
cancelText = "Cancel",
confirmText,
description,
onClose,
onConfirm,
open,
pending = false,
title,
}: ConfirmationDialogProps) {
if (!open) return null;

const handleClose = () => {
if (!pending) {
onClose();
}
};

return (
<Dialog className="relative z-[60]" onClose={handleClose} open>
<DialogBackdrop
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"
transition
/>

<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel
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"
transition
>
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 dark:bg-gray-800">
<DialogTitle
as="h3"
className="text-base font-semibold text-gray-900 dark:text-white"
>
{title}
</DialogTitle>
<Description
as="div"
className="mt-2 text-sm text-gray-600 dark:text-gray-400"
>
{description}
</Description>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
<button
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"
disabled={pending}
onClick={onConfirm}
type="button"
>
{confirmText}
</button>
<button
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"
data-autofocus
disabled={pending}
onClick={handleClose}
type="button"
>
{cancelText}
</button>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
);
}
79 changes: 69 additions & 10 deletions src/components/JobDetail.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,75 @@
import { jobFactory } from "@test/factories/job";
import { render } from "@testing-library/react";
import { expect, test } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "storybook/test";
import { expect, test, vi } from "vitest";

import JobDetail from "./JobDetail";

test("adds 1 + 2 to equal 3", () => {
const job = jobFactory.build();
const cancel = () => {};
const deleteFn = () => {};
const retry = () => {};
const { getByTestId: _getTestById } = render(
<JobDetail cancel={cancel} deleteFn={deleteFn} job={job} retry={retry} />,
test("requires confirmation before deleting a job", async () => {
const job = jobFactory.completed().build({ id: 123n });
const deleteFn = vi.fn();
const user = userEvent.setup();

render(
<JobDetail
cancel={vi.fn()}
deleteFn={deleteFn}
job={job}
retry={vi.fn()}
/>,
);
expect(3).toBe(3);

await act(async () => {
await user.click(screen.getByRole("button", { name: /^delete$/i }));
});

expect(deleteFn).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", { name: "Delete job?" });
expect(
within(dialog).getByText(/This permanently deletes job/i),
).toBeInTheDocument();
expect(within(dialog).getByText("123")).toBeInTheDocument();

await act(async () => {
await user.click(
within(dialog).getByRole("button", { name: /delete job/i }),
);
});

await waitFor(() => {
expect(deleteFn).toHaveBeenCalledTimes(1);
expect(
screen.queryByRole("dialog", { name: "Delete job?" }),
).not.toBeInTheDocument();
});
});

test("cancels job delete confirmation", async () => {
const job = jobFactory.completed().build();
const deleteFn = vi.fn();
const user = userEvent.setup();

render(
<JobDetail
cancel={vi.fn()}
deleteFn={deleteFn}
job={job}
retry={vi.fn()}
/>,
);

await act(async () => {
await user.click(screen.getByRole("button", { name: /^delete$/i }));
});
const dialog = await screen.findByRole("dialog", { name: "Delete job?" });
await act(async () => {
await user.click(within(dialog).getByRole("button", { name: /cancel/i }));
});

await waitFor(() => {
expect(deleteFn).not.toHaveBeenCalled();
expect(
screen.queryByRole("dialog", { name: "Delete job?" }),
).not.toBeInTheDocument();
});
});
64 changes: 44 additions & 20 deletions src/components/JobDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Badge } from "@components/Badge";
import ButtonForGroup from "@components/ButtonForGroup";
import ConfirmationDialog from "@components/ConfirmationDialog";
import JobAttempts from "@components/JobAttempts";
import JobTimeline from "@components/JobTimeline";
import JSONView from "@components/JSONView";
Expand All @@ -15,7 +16,7 @@ import { Job, JobWithKnownMetadata } from "@services/jobs";
import { JobState } from "@services/types";
import { Link } from "@tanstack/react-router";
import { capitalize } from "@utils/string";
import { FormEvent } from "react";
import { FormEvent, useState } from "react";

type JobDetailProps = {
cancel: () => void;
Expand Down Expand Up @@ -185,12 +186,19 @@ export default function JobDetail({
}

function ActionButtons({ cancel, deleteFn, job, retry }: JobDetailProps) {
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);

// Can only delete jobs that aren't running:
const deleteDisabled = job.state === JobState.Running;

const deleteJob = (event: FormEvent) => {
event.preventDefault();
setDeleteConfirmationOpen(true);
};

const confirmDelete = () => {
deleteFn();
setDeleteConfirmationOpen(false);
};

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

return (
<span className="isolate inline-flex rounded-md shadow-xs">
<ButtonForGroup
disabled={retryDisabled}
Icon={ArrowUturnLeftIcon}
onClick={retryJob}
text="Retry"
/>
<ButtonForGroup
disabled={cancelDisabled}
Icon={XCircleIcon}
onClick={cancelJob}
text="Cancel"
/>
<ButtonForGroup
disabled={deleteDisabled}
Icon={TrashIcon}
onClick={deleteJob}
text="Delete"
<>
<span className="isolate inline-flex rounded-md shadow-xs">
<ButtonForGroup
disabled={retryDisabled}
Icon={ArrowUturnLeftIcon}
onClick={retryJob}
text="Retry"
/>
<ButtonForGroup
disabled={cancelDisabled}
Icon={XCircleIcon}
onClick={cancelJob}
text="Cancel"
/>
<ButtonForGroup
disabled={deleteDisabled}
Icon={TrashIcon}
onClick={deleteJob}
text="Delete"
/>
</span>
<ConfirmationDialog
confirmText="Delete job"
description={
<>
This permanently deletes job{" "}
<span className="font-mono">{job.id.toString()}</span>. This action
cannot be undone.
</>
}
onClose={() => setDeleteConfirmationOpen(false)}
onConfirm={confirmDelete}
open={deleteConfirmationOpen}
title="Delete job?"
/>
</span>
</>
);
}

Expand Down
Loading
Loading