Skip to content

Commit 8af8e59

Browse files
authored
feat: bulk job deletion with multi-select UI (#481)
Closes #445
1 parent 9ac5e75 commit 8af8e59

File tree

2 files changed

+222
-36
lines changed

2 files changed

+222
-36
lines changed

src/pages/deleteJob.tsx

Lines changed: 108 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,115 @@
11
import { rmSync } from "node:fs";
2-
import { Elysia } from "elysia";
2+
import { Elysia, t } from "elysia";
33
import { outputDir, uploadsDir } from "..";
44
import db from "../db/db";
55
import { WEBROOT } from "../helpers/env";
66
import { userService } from "./user";
77
import { Jobs } from "../db/types";
88

9-
export const deleteJob = new Elysia().use(userService).get(
10-
"/delete/:jobId",
11-
async ({ params, redirect, user }) => {
12-
const job = db
13-
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
14-
.as(Jobs)
15-
.get(user.id, params.jobId);
16-
17-
if (!job) {
18-
return redirect(`${WEBROOT}/results`, 302);
19-
}
20-
21-
// delete the directories
22-
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
23-
recursive: true,
24-
force: true,
25-
});
26-
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
27-
recursive: true,
28-
force: true,
29-
});
30-
31-
// delete the job
32-
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
33-
return redirect(`${WEBROOT}/history`, 302);
34-
},
35-
{
36-
auth: true,
37-
},
38-
);
9+
export const deleteJob = new Elysia()
10+
.use(userService)
11+
.get(
12+
"/delete/:jobId",
13+
async ({ params, redirect, user }) => {
14+
const job = db
15+
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
16+
.as(Jobs)
17+
.get(user.id, params.jobId);
18+
19+
if (!job) {
20+
return redirect(`${WEBROOT}/results`, 302);
21+
}
22+
23+
// delete the directories
24+
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
25+
recursive: true,
26+
force: true,
27+
});
28+
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
29+
recursive: true,
30+
force: true,
31+
});
32+
33+
// delete the job
34+
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
35+
return redirect(`${WEBROOT}/history`, 302);
36+
},
37+
{
38+
auth: true,
39+
},
40+
)
41+
.post(
42+
"/delete-multiple",
43+
async ({ body, user, set }) => {
44+
const { jobIds } = body;
45+
46+
if (!Array.isArray(jobIds) || jobIds.length === 0) {
47+
set.status = 400;
48+
return { success: false, message: "Invalid job IDs provided" };
49+
}
50+
51+
const results = {
52+
success: [] as string[],
53+
failed: [] as { jobId: string; error: string }[],
54+
};
55+
56+
// Process deletions sequentially for safety
57+
for (const jobId of jobIds) {
58+
try {
59+
const job = db
60+
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
61+
.as(Jobs)
62+
.get(user.id, jobId);
63+
64+
if (!job) {
65+
results.failed.push({
66+
jobId,
67+
error: "Job not found or unauthorized",
68+
});
69+
continue;
70+
}
71+
72+
// Delete the directories
73+
try {
74+
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
75+
recursive: true,
76+
force: true,
77+
});
78+
} catch (error) {
79+
console.error(`Failed to delete output directory for job ${jobId}:`, error);
80+
}
81+
82+
try {
83+
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
84+
recursive: true,
85+
force: true,
86+
});
87+
} catch (error) {
88+
console.error(`Failed to delete uploads directory for job ${jobId}:`, error);
89+
}
90+
91+
// Delete the job from database
92+
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
93+
results.success.push(jobId);
94+
} catch (error) {
95+
results.failed.push({
96+
jobId,
97+
error: error instanceof Error ? error.message : "Unknown error",
98+
});
99+
}
100+
}
101+
102+
return {
103+
success: results.failed.length === 0,
104+
deleted: results.success.length,
105+
failed: results.failed.length,
106+
details: results,
107+
};
108+
},
109+
{
110+
auth: true,
111+
body: t.Object({
112+
jobIds: t.Array(t.String(), { maxItems: 100 }),
113+
}),
114+
},
115+
);

src/pages/history.tsx

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,24 @@ export const history = new Elysia().use(userService).get(
4747
`}
4848
>
4949
<article class="article">
50-
<h1 class="mb-4 text-xl">Results</h1>
50+
<div class="mb-4 flex items-center justify-between">
51+
<h1 class="text-xl">Results</h1>
52+
<div id="delete-selected-container">
53+
<button
54+
id="delete-selected-btn"
55+
class={`
56+
flex btn-secondary flex-row gap-2 text-contrast
57+
disabled:cursor-not-allowed disabled:opacity-50
58+
`}
59+
disabled
60+
>
61+
<DeleteIcon />{" "}
62+
<span>
63+
Delete Selected (<span id="selected-count">0</span>)
64+
</span>
65+
</button>
66+
</div>
67+
</div>
5168
<table
5269
class={`
5370
w-full table-auto overflow-y-auto rounded bg-neutral-900 text-left
@@ -57,6 +74,19 @@ export const history = new Elysia().use(userService).get(
5774
>
5875
<thead>
5976
<tr>
77+
<th
78+
class={`
79+
px-2 py-2
80+
sm:px-4
81+
`}
82+
>
83+
<input
84+
type="checkbox"
85+
id="select-all"
86+
class="h-4 w-4 cursor-pointer"
87+
title="Select all"
88+
/>
89+
</th>
6090
<th
6191
class={`
6292
px-2 py-2
@@ -112,6 +142,14 @@ export const history = new Elysia().use(userService).get(
112142
{userJobs.map((job) => (
113143
<>
114144
<tr id={`job-row-${job.id}`}>
145+
<td>
146+
<input
147+
type="checkbox"
148+
class="h-4 w-4 cursor-pointer"
149+
data-checkbox-type="job"
150+
data-job-id={job.id}
151+
/>
152+
</td>
115153
<td class="job-details-toggle cursor-pointer" data-job-id={job.id}>
116154
<svg
117155
id={`arrow-${job.id}`}
@@ -159,7 +197,7 @@ export const history = new Elysia().use(userService).get(
159197
</td>
160198
</tr>
161199
<tr id={`details-${job.id}`} class="hidden">
162-
<td colspan="6">
200+
<td colspan="7">
163201
<div class="p-2 text-sm text-neutral-500">
164202
<div class="mb-1 font-semibold">Detailed File Information:</div>
165203
{job.files_detailed.map((file: Filename) => (
@@ -196,26 +234,97 @@ export const history = new Elysia().use(userService).get(
196234
<script>
197235
{`
198236
document.addEventListener('DOMContentLoaded', () => {
237+
// Expand/collapse job details
199238
const toggles = document.querySelectorAll('.job-details-toggle');
200239
toggles.forEach(toggle => {
201240
toggle.addEventListener('click', function() {
202241
const jobId = this.dataset.jobId;
203242
const detailsRow = document.getElementById(\`details-\${jobId}\`);
204-
// The arrow SVG itself has the ID arrow-\${jobId}
205243
const arrow = document.getElementById(\`arrow-\${jobId}\`);
206244
207245
if (detailsRow && arrow) {
208246
detailsRow.classList.toggle("hidden");
209247
if (detailsRow.classList.contains("hidden")) {
210-
// Right-facing arrow (collapsed)
211248
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />';
212249
} else {
213-
// Down-facing arrow (expanded)
214250
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />';
215251
}
216252
}
217253
});
218254
});
255+
256+
// Checkbox management
257+
const selectAllCheckbox = document.getElementById('select-all');
258+
const jobCheckboxes = document.querySelectorAll('[data-checkbox-type="job"]');
259+
const deleteSelectedBtn = document.getElementById('delete-selected-btn');
260+
const deleteSelectedContainer = document.getElementById('delete-selected-container');
261+
const selectedCountSpan = document.getElementById('selected-count');
262+
263+
function updateDeleteButton() {
264+
const checkedBoxes = Array.from(jobCheckboxes).filter(cb => cb.checked);
265+
if (checkedBoxes.length > 0) {
266+
deleteSelectedBtn.disabled = false;
267+
selectedCountSpan.textContent = checkedBoxes.length;
268+
} else {
269+
deleteSelectedBtn.disabled = true;
270+
selectedCountSpan.textContent = '0';
271+
}
272+
}
273+
274+
selectAllCheckbox?.addEventListener('change', function() {
275+
jobCheckboxes.forEach(checkbox => {
276+
checkbox.checked = this.checked;
277+
});
278+
updateDeleteButton();
279+
});
280+
281+
jobCheckboxes.forEach(checkbox => {
282+
checkbox.addEventListener('change', function() {
283+
const allChecked = Array.from(jobCheckboxes).every(cb => cb.checked);
284+
const someChecked = Array.from(jobCheckboxes).some(cb => cb.checked);
285+
if (selectAllCheckbox) {
286+
selectAllCheckbox.checked = allChecked;
287+
selectAllCheckbox.indeterminate = someChecked && !allChecked;
288+
}
289+
updateDeleteButton();
290+
});
291+
});
292+
293+
deleteSelectedBtn?.addEventListener('click', async function() {
294+
const checkedBoxes = Array.from(jobCheckboxes).filter(cb => cb.checked);
295+
const jobIds = checkedBoxes.map(cb => cb.dataset.jobId);
296+
297+
if (jobIds.length === 0) return;
298+
299+
const confirmed = confirm(\`Are you sure you want to delete \${jobIds.length} job(s)? This action cannot be undone.\`);
300+
if (!confirmed) return;
301+
302+
try {
303+
const response = await fetch('${WEBROOT}/delete-multiple', {
304+
method: 'POST',
305+
headers: {
306+
'Content-Type': 'application/json',
307+
},
308+
body: JSON.stringify({ jobIds }),
309+
});
310+
311+
if (!response.ok) {
312+
throw new Error(\`HTTP error! status: \${response.status}\`);
313+
}
314+
315+
const result = await response.json();
316+
317+
if (result.success || result.deleted > 0) {
318+
alert(\`Successfully deleted \${result.deleted} job(s).\${result.failed > 0 ? \` Failed to delete \${result.failed} job(s).\` : ''}\`);
319+
window.location.reload();
320+
} else {
321+
alert('Failed to delete jobs. Please try again.');
322+
}
323+
} catch (error) {
324+
console.error('Error deleting jobs:', error);
325+
alert('An error occurred while deleting jobs. Please try again.');
326+
}
327+
});
219328
});
220329
`}
221330
</script>

0 commit comments

Comments
 (0)