Skip to content

Commit 00c43aa

Browse files
authored
Merge pull request #2878 from homarr-labs/feat/bulk-reject-submissions
feat(dashboard): add bulk reject for admin submissions
2 parents a709a30 + b5df500 commit 00c43aa

3 files changed

Lines changed: 205 additions & 2 deletions

File tree

web/src/app/dashboard/page.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
useApproveSubmission,
2020
useAuth,
2121
useBulkApproveSubmissions,
22+
useBulkRejectSubmissions,
2223
useBulkTriggerWorkflow,
2324
useRejectSubmission,
2425
useSubmissions,
@@ -94,6 +95,7 @@ export default function DashboardPage() {
9495
const workflowMutation = useTriggerWorkflow()
9596
const bulkWorkflowMutation = useBulkTriggerWorkflow()
9697
const bulkApproveMutation = useBulkApproveSubmissions()
98+
const bulkRejectMutation = useBulkRejectSubmissions()
9799

98100
const [workflowUrl, setWorkflowUrl] = React.useState<string | undefined>()
99101
const [approveDialogOpen, setApproveDialogOpen] = React.useState(false)
@@ -102,6 +104,9 @@ export default function DashboardPage() {
102104
const [bulkApproveDialogOpen, setBulkApproveDialogOpen] = React.useState(false)
103105
const [bulkApprovingIds, setBulkApprovingIds] = React.useState<string[]>([])
104106
const [bulkApproveAdminComment, setBulkApproveAdminComment] = React.useState("")
107+
const [bulkRejectDialogOpen, setBulkRejectDialogOpen] = React.useState(false)
108+
const [bulkRejectingIds, setBulkRejectingIds] = React.useState<string[]>([])
109+
const [bulkRejectAdminComment, setBulkRejectAdminComment] = React.useState("")
105110
const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
106111
const [rejectingSubmissionId, setRejectingSubmissionId] = React.useState<string | null>(null)
107112
const [adminComment, setAdminComment] = React.useState("")
@@ -212,6 +217,27 @@ export default function DashboardPage() {
212217
}
213218
}
214219

220+
const handleBulkReject = (submissionIds: string[]) => {
221+
setBulkRejectingIds(submissionIds)
222+
setBulkRejectAdminComment("")
223+
setBulkRejectDialogOpen(true)
224+
}
225+
226+
const handleBulkRejectSubmit = () => {
227+
if (bulkRejectingIds.length > 0) {
228+
bulkRejectMutation.mutate(
229+
{ submissionIds: [...bulkRejectingIds], adminComment: bulkRejectAdminComment.trim() || undefined },
230+
{
231+
onSuccess: () => {
232+
setBulkRejectDialogOpen(false)
233+
setBulkRejectingIds([])
234+
setBulkRejectAdminComment("")
235+
},
236+
},
237+
)
238+
}
239+
}
240+
215241
// Not authenticated
216242
if (!authLoading && !isAuthenticated) {
217243
return (
@@ -329,11 +355,13 @@ export default function DashboardPage() {
329355
onTriggerWorkflow={handleTriggerWorkflow}
330356
onBulkTriggerWorkflow={handleBulkTriggerWorkflow}
331357
onBulkApprove={handleBulkApprove}
358+
onBulkReject={handleBulkReject}
332359
isApproving={approveMutation.isPending}
333360
isRejecting={rejectMutation.isPending}
334361
isTriggeringWorkflow={workflowMutation.isPending}
335362
isBulkTriggeringWorkflow={bulkWorkflowMutation.isPending}
336363
isBulkApproving={bulkApproveMutation.isPending}
364+
isBulkRejecting={bulkRejectMutation.isPending}
337365
workflowUrl={workflowUrl}
338366
hideStatusHints
339367
/>
@@ -353,11 +381,13 @@ export default function DashboardPage() {
353381
onTriggerWorkflow={handleTriggerWorkflow}
354382
onBulkTriggerWorkflow={handleBulkTriggerWorkflow}
355383
onBulkApprove={handleBulkApprove}
384+
onBulkReject={handleBulkReject}
356385
isApproving={approveMutation.isPending}
357386
isRejecting={rejectMutation.isPending}
358387
isTriggeringWorkflow={workflowMutation.isPending}
359388
isBulkTriggeringWorkflow={bulkWorkflowMutation.isPending}
360389
isBulkApproving={bulkApproveMutation.isPending}
390+
isBulkRejecting={bulkRejectMutation.isPending}
361391
workflowUrl={workflowUrl}
362392
hideStatusHints
363393
/>
@@ -377,11 +407,13 @@ export default function DashboardPage() {
377407
onTriggerWorkflow={handleTriggerWorkflow}
378408
onBulkTriggerWorkflow={handleBulkTriggerWorkflow}
379409
onBulkApprove={handleBulkApprove}
410+
onBulkReject={handleBulkReject}
380411
isApproving={approveMutation.isPending}
381412
isRejecting={rejectMutation.isPending}
382413
isTriggeringWorkflow={workflowMutation.isPending}
383414
isBulkTriggeringWorkflow={bulkWorkflowMutation.isPending}
384415
isBulkApproving={bulkApproveMutation.isPending}
416+
isBulkRejecting={bulkRejectMutation.isPending}
385417
workflowUrl={workflowUrl}
386418
/>
387419
)}
@@ -430,6 +462,20 @@ export default function DashboardPage() {
430462
onSubmit={handleBulkApproveSubmit}
431463
isPending={bulkApproveMutation.isPending}
432464
/>
465+
<BulkRejectDialog
466+
isMobile={isMobile}
467+
open={bulkRejectDialogOpen}
468+
onClose={() => {
469+
setBulkRejectDialogOpen(false)
470+
setBulkRejectingIds([])
471+
setBulkRejectAdminComment("")
472+
}}
473+
count={bulkRejectingIds.length}
474+
comment={bulkRejectAdminComment}
475+
onCommentChange={setBulkRejectAdminComment}
476+
onSubmit={handleBulkRejectSubmit}
477+
isPending={bulkRejectMutation.isPending}
478+
/>
433479
</>
434480
)
435481
}
@@ -753,3 +799,87 @@ function BulkApproveDialog({
753799
</Dialog>
754800
)
755801
}
802+
803+
function BulkRejectDialog({
804+
isMobile,
805+
open,
806+
onClose,
807+
count,
808+
comment,
809+
onCommentChange,
810+
onSubmit,
811+
isPending,
812+
}: {
813+
isMobile: boolean
814+
open: boolean
815+
onClose: () => void
816+
count: number
817+
comment: string
818+
onCommentChange: (v: string) => void
819+
onSubmit: () => void
820+
isPending: boolean
821+
}) {
822+
const label = `Reject ${count} Submission${count > 1 ? "s" : ""}`
823+
824+
if (isMobile) {
825+
return (
826+
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
827+
<DrawerContent>
828+
<DrawerHeader className="text-left">
829+
<DrawerTitle>{label}</DrawerTitle>
830+
<DrawerDescription>All submitters will see the rejection reason.</DrawerDescription>
831+
</DrawerHeader>
832+
<div className="px-4 pb-2 space-y-2">
833+
<Label htmlFor="bulk-reject-comment">Reason</Label>
834+
<Textarea
835+
id="bulk-reject-comment"
836+
placeholder="e.g. Icons don't meet quality guidelines..."
837+
value={comment}
838+
onChange={(e) => onCommentChange(e.target.value)}
839+
rows={3}
840+
/>
841+
</div>
842+
<DrawerFooter>
843+
<Button variant="destructive" onClick={onSubmit} disabled={isPending}>
844+
{isPending ? "Rejecting..." : label}
845+
</Button>
846+
<DrawerClose asChild>
847+
<Button variant="outline" disabled={isPending}>
848+
Cancel
849+
</Button>
850+
</DrawerClose>
851+
</DrawerFooter>
852+
</DrawerContent>
853+
</Drawer>
854+
)
855+
}
856+
857+
return (
858+
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
859+
<DialogContent>
860+
<DialogHeader>
861+
<DialogTitle>{label}</DialogTitle>
862+
<DialogDescription>All submitters will see the rejection reason.</DialogDescription>
863+
</DialogHeader>
864+
<div className="space-y-2 py-4">
865+
<Label htmlFor="bulk-reject-comment">Reason</Label>
866+
<Textarea
867+
id="bulk-reject-comment"
868+
placeholder="e.g. Icons don't meet quality guidelines..."
869+
value={comment}
870+
onChange={(e) => onCommentChange(e.target.value)}
871+
rows={3}
872+
/>
873+
</div>
874+
<DialogFooter>
875+
<Button variant="outline" onClick={onClose} disabled={isPending}>
876+
Cancel
877+
</Button>
878+
<Button variant="destructive" onClick={onSubmit} disabled={isPending}>
879+
{isPending ? "Rejecting..." : label}
880+
</Button>
881+
</DialogFooter>
882+
</DialogContent>
883+
</Dialog>
884+
)
885+
}

web/src/components/submissions-data-table.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from "@tanstack/react-table"
1616
import dayjs from "dayjs"
1717
import relativeTime from "dayjs/plugin/relativeTime"
18-
import { CheckCircle2, ChevronDown, ChevronRight, Filter, Github, ImageIcon, Rocket, Search, SortDesc, X } from "lucide-react"
18+
import { CheckCircle2, ChevronDown, ChevronRight, Filter, Github, ImageIcon, Rocket, Search, SortDesc, X, XCircle } from "lucide-react"
1919
import * as React from "react"
2020
import { StatusBadge } from "@/components/status-badge"
2121
import { SubmissionDetails } from "@/components/submission-details"
@@ -62,11 +62,13 @@ interface SubmissionsDataTableProps {
6262
onTriggerWorkflow?: (id: string) => void
6363
onBulkTriggerWorkflow?: (ids: string[]) => void
6464
onBulkApprove?: (ids: string[]) => void
65+
onBulkReject?: (ids: string[]) => void
6566
isApproving?: boolean
6667
isRejecting?: boolean
6768
isTriggeringWorkflow?: boolean
6869
isBulkTriggeringWorkflow?: boolean
6970
isBulkApproving?: boolean
71+
isBulkRejecting?: boolean
7072
workflowUrl?: string
7173
hideStatusHints?: boolean
7274
}
@@ -96,11 +98,13 @@ export function SubmissionsDataTable({
9698
onTriggerWorkflow,
9799
onBulkTriggerWorkflow,
98100
onBulkApprove,
101+
onBulkReject,
99102
isApproving,
100103
isRejecting,
101104
isTriggeringWorkflow,
102105
isBulkTriggeringWorkflow,
103106
isBulkApproving,
107+
isBulkRejecting,
104108
workflowUrl,
105109
hideStatusHints,
106110
}: SubmissionsDataTableProps) {
@@ -400,7 +404,19 @@ export function SubmissionsDataTable({
400404
const handleBulkApprove = () => {
401405
if (onBulkApprove && selectedPendingIds.length > 0) {
402406
onBulkApprove(selectedPendingIds)
403-
// Only clear pending selections
407+
setRowSelection(() => {
408+
const next: RowSelectionState = {}
409+
for (const id of selectedApprovedIds) {
410+
next[id] = true
411+
}
412+
return next
413+
})
414+
}
415+
}
416+
417+
const handleBulkReject = () => {
418+
if (onBulkReject && selectedPendingIds.length > 0) {
419+
onBulkReject(selectedPendingIds)
404420
setRowSelection(() => {
405421
const next: RowSelectionState = {}
406422
for (const id of selectedApprovedIds) {
@@ -501,6 +517,18 @@ export function SubmissionsDataTable({
501517
<Button variant="ghost" size="sm" onClick={() => setRowSelection({})} className="flex-1 sm:flex-none">
502518
Clear selection
503519
</Button>
520+
{selectedPendingIds.length > 0 && onBulkReject && (
521+
<Button
522+
variant="outline"
523+
size="sm"
524+
onClick={handleBulkReject}
525+
disabled={isBulkRejecting}
526+
className="flex-1 sm:flex-none border-red-500/30 text-red-600 hover:bg-red-500/10 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
527+
>
528+
<XCircle className="w-4 h-4 mr-2" />
529+
{isBulkRejecting ? "Rejecting..." : `Reject (${selectedPendingIds.length})`}
530+
</Button>
531+
)}
504532
{selectedPendingIds.length > 0 && onBulkApprove && (
505533
<Button
506534
size="sm"

web/src/hooks/use-submissions.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,51 @@ export function useBulkApproveSubmissions() {
110110
})
111111
}
112112

113+
export function useBulkRejectSubmissions() {
114+
const queryClient = useQueryClient()
115+
116+
return useMutation({
117+
mutationFn: async ({ submissionIds, adminComment }: { submissionIds: string[]; adminComment?: string }) => {
118+
const results = await Promise.allSettled(
119+
submissionIds.map((submissionId) =>
120+
pb.collection("submissions").update(
121+
submissionId,
122+
{
123+
status: "rejected",
124+
approved_by: pb.authStore.record?.id || "",
125+
admin_comment: adminComment || "",
126+
},
127+
{ requestKey: null },
128+
),
129+
),
130+
)
131+
const fulfilled = results.filter((r) => r.status === "fulfilled")
132+
const rejected = results.filter((r) => r.status === "rejected")
133+
if (rejected.length > 0 && fulfilled.length === 0) {
134+
throw new Error(`All ${rejected.length} rejections failed`)
135+
}
136+
return { fulfilled: fulfilled.length, rejected: rejected.length, total: results.length }
137+
},
138+
onSuccess: async (data) => {
139+
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
140+
await revalidateAllSubmissions()
141+
if (data.rejected > 0) {
142+
toast.warning(`${data.fulfilled} of ${data.total} submissions rejected, ${data.rejected} failed`)
143+
} else {
144+
toast.success(`${data.total} submission${data.total > 1 ? "s" : ""} rejected`)
145+
}
146+
},
147+
onError: (error: any) => {
148+
console.error("Error bulk rejecting submissions:", error)
149+
if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) {
150+
toast.error("Failed to reject submissions", {
151+
description: error.message || "An error occurred",
152+
})
153+
}
154+
},
155+
})
156+
}
157+
113158
// Reject submission mutation
114159
export function useRejectSubmission() {
115160
const queryClient = useQueryClient()

0 commit comments

Comments
 (0)