From 1e1017d2be5df58bd8b8bfd094b59895c7c22c92 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Wed, 1 Jul 2026 14:57:44 -0400 Subject: [PATCH 1/3] [Moderation] Adding multi select and pagination to pending works moderation --- components/Moderators/DeclineModal.tsx | 16 +- .../Moderators/PendingModerationList.tsx | 494 ++++++++++++++---- components/Moderators/SelectionCheckbox.tsx | 11 +- 3 files changed, 427 insertions(+), 94 deletions(-) diff --git a/components/Moderators/DeclineModal.tsx b/components/Moderators/DeclineModal.tsx index dc027d176..a0163f2ef 100644 --- a/components/Moderators/DeclineModal.tsx +++ b/components/Moderators/DeclineModal.tsx @@ -17,6 +17,10 @@ interface DeclineModalProps { isSubmitting?: boolean; /** Singular noun for the content being declined (e.g. "RFP", "Proposal"). */ itemLabel?: string; + title?: string; + reasonPrompt?: string; + confirmText?: string; + submittingText?: string; } export const DeclineModal: FC = ({ @@ -25,6 +29,10 @@ export const DeclineModal: FC = ({ onConfirm, isSubmitting = false, itemLabel = 'submission', + title, + reasonPrompt, + confirmText = 'Decline', + submittingText = 'Declining...', }) => { const [selectedReason, setSelectedReason] = useState(null); const [reasonText, setReasonText] = useState(''); @@ -51,7 +59,7 @@ export const DeclineModal: FC = ({ @@ -65,19 +73,19 @@ export const DeclineModal: FC = ({ disabled={!selectedReason || isSubmitting} className="bg-red-600 hover:bg-red-700 text-white" > - {isSubmitting ? 'Declining...' : 'Decline'} + {isSubmitting ? submittingText : confirmText} } >

- I am declining this {itemLabel.toLowerCase()} because of: + {reasonPrompt ?? `I am declining this ${itemLabel.toLowerCase()} because of:`}

setSelectedReason(value as FlagReasonKey)} className="space-y-1.5" /> diff --git a/components/Moderators/PendingModerationList.tsx b/components/Moderators/PendingModerationList.tsx index 48230e237..b14e44216 100644 --- a/components/Moderators/PendingModerationList.tsx +++ b/components/Moderators/PendingModerationList.tsx @@ -1,12 +1,15 @@ 'use client'; import { ReactNode, useCallback, useEffect, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; import { RefreshCw, CheckCircle, XCircle } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { DeclineModal } from '@/components/Moderators/DeclineModal'; +import { ConfirmModal } from '@/components/modals/ConfirmModal'; import { FeedItemGrantWithApplicants } from '@/components/Feed/items/FeedItemGrantWithApplicants'; import { FeedItemPost } from '@/components/Feed/items/FeedItemPost'; import { FeedItemPaper } from '@/components/Feed/items/FeedItemPaper'; +import { SelectionCheckbox } from '@/components/Moderators/SelectionCheckbox'; import { PendingModerationService, PENDING_MODULE_CONFIG, @@ -22,6 +25,12 @@ interface PendingModerationListProps { module: PendingModule; } +type ModerationAction = 'approve' | 'decline'; +type DeclineData = { reasonChoice: FlagReasonKey; reason: string }; +type SelectablePendingEntry = { entryId: string; contentId: number }; + +const BULK_ACTION_BATCH_SIZE = 5; + function getContentId(module: PendingModule, entry: FeedEntry): number | undefined { if (module === 'funding_opportunities') { return (entry.content as FeedGrantContent).grant?.id; @@ -29,6 +38,16 @@ function getContentId(module: PendingModule, entry: FeedEntry): number | undefin return entry.content.id; } +function getSelectableEntries( + module: PendingModule, + entries: FeedEntry[] +): SelectablePendingEntry[] { + return entries.flatMap((entry) => { + const contentId = getContentId(module, entry); + return contentId == null ? [] : [{ entryId: entry.id, contentId }]; + }); +} + function getGrantEntryWithRiskScore(entry: FeedEntry): FeedEntry { if (entry.riskScore == null) return entry; @@ -81,100 +100,396 @@ function renderFeedItem(module: PendingModule, entry: FeedEntry, footer: ReactNo } } +async function runModerationBatch({ + entries, + moderate, + onProgress, +}: { + entries: SelectablePendingEntry[]; + moderate: (entry: SelectablePendingEntry) => Promise; + onProgress: () => void; +}): Promise> { + const succeededEntryIds = new Set(); + + for (let index = 0; index < entries.length; index += BULK_ACTION_BATCH_SIZE) { + const batch = entries.slice(index, index + BULK_ACTION_BATCH_SIZE); + const results = await Promise.allSettled(batch.map(moderate)); + + results.forEach((result, batchIndex) => { + if (result.status === 'fulfilled') { + succeededEntryIds.add(batch[batchIndex].entryId); + } + onProgress(); + }); + } + + return succeededEntryIds; +} + +function BulkProcessingOverlay({ + action, + processed, + total, +}: Readonly<{ + action: ModerationAction; + processed: number; + total: number; +}>) { + const actionLabel = action === 'approve' ? 'Approving' : 'Declining'; + + return ( +
+
+
+ +
+

+ {actionLabel} selected submissions +

+

+ Processing {Math.min(processed, total)} of {total}. Please keep this page open. +

+
+
+ ); +} + +function PendingModerationSkeleton({ count = 3 }: Readonly<{ count?: number }>) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+ ); +} + export function PendingModerationList({ module }: Readonly) { - const { itemLabel } = PENDING_MODULE_CONFIG[module]; + const { itemLabel, tabLabel } = PENDING_MODULE_CONFIG[module]; const { refreshPendingCounts } = usePendingCounts(); const [entries, setEntries] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); const [actioningId, setActioningId] = useState(null); - const [declineTarget, setDeclineTarget] = useState<{ id: number; entryId: string } | null>(null); + const [declineTargetId, setDeclineTargetId] = useState(null); + const [selectedEntryIds, setSelectedEntryIds] = useState>(new Set()); + const [bulkAction, setBulkAction] = useState(null); + const [bulkProcessedCount, setBulkProcessedCount] = useState(0); + const [bulkTotalCount, setBulkTotalCount] = useState(0); + const [showBulkApproveConfirm, setShowBulkApproveConfirm] = useState(false); + const [showBulkDeclineModal, setShowBulkDeclineModal] = useState(false); + const { ref: loadMoreRef, inView: isLoadMoreInView } = useInView({ + threshold: 0, + rootMargin: '100px', + }); - const fetchEntries = useCallback(async () => { - setIsLoading(true); - try { - const response = await PendingModerationService.fetchPending(module); - setEntries(response.entries); - } catch { - toast.error('Failed to load pending submissions'); - } finally { - setIsLoading(false); - setIsRefreshing(false); - } - }, [module]); + const fetchEntries = useCallback( + async (pageToLoad = 1, append = false) => { + if (append) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + } + + try { + const response = await PendingModerationService.fetchPending(module, pageToLoad); + setEntries((currentEntries) => + append ? [...currentEntries, ...response.entries] : response.entries + ); + setHasMore(response.hasMore); + setPage(pageToLoad); + if (!append) { + setSelectedEntryIds(new Set()); + } + } catch { + toast.error('Failed to load pending submissions'); + } finally { + setIsLoading(false); + setIsLoadingMore(false); + setIsRefreshing(false); + } + }, + [module] + ); useEffect(() => { fetchEntries(); }, [fetchEntries]); - const handleRefresh = async () => { + const refreshPendingData = useCallback(async () => { setIsRefreshing(true); - refreshPendingCounts({ force: true }).catch(() => undefined); - await fetchEntries(); - }; + await Promise.all([ + refreshPendingCounts({ force: true }).catch(() => undefined), + fetchEntries(1), + ]); + }, [fetchEntries, refreshPendingCounts]); - const handleApprove = async (id: number, entryId: string) => { - setActioningId(id); + const selectableEntries = getSelectableEntries(module, entries); + const selectedEntries = selectableEntries.filter((entry) => selectedEntryIds.has(entry.entryId)); + const selectedCount = selectedEntries.length; + const selectableCount = selectableEntries.length; + const selectedItemLabel = selectedCount === 1 ? itemLabel.toLowerCase() : tabLabel.toLowerCase(); + const allLoadedEntriesSelected = selectableCount > 0 && selectedCount === selectableCount; + const someLoadedEntriesSelected = selectedCount > 0 && selectedCount < selectableCount; + const isBulkActioning = bulkAction != null; + + const loadNextPage = useCallback(async () => { + if (!hasMore || isLoadingMore) return; + await fetchEntries(page + 1, true); + }, [fetchEntries, hasMore, isLoadingMore, page]); + + useEffect(() => { + if (!isLoadMoreInView || isLoading || isLoadingMore || isBulkActioning || !hasMore) return; + loadNextPage(); + }, [hasMore, isBulkActioning, isLoadMoreInView, isLoading, isLoadingMore, loadNextPage]); + + const approveEntry = async (contentId: number) => { + setActioningId(contentId); try { - await PendingModerationService.approve(module, id); + await PendingModerationService.approve(module, contentId); toast.success(`${itemLabel} approved`); - setEntries((prev) => prev.filter((e) => e.id !== entryId)); - refreshPendingCounts({ force: true }).catch(() => undefined); - } catch (err) { - toast.error(err instanceof Error ? err.message : `Failed to approve ${itemLabel}`); + await refreshPendingData(); + } catch (error) { + toast.error(error instanceof Error ? error.message : `Failed to approve ${itemLabel}`); } finally { setActioningId(null); } }; - const handleDecline = async (data: { reasonChoice: FlagReasonKey; reason: string }) => { - if (!declineTarget) return; - setActioningId(declineTarget.id); + const declineEntry = async ({ reasonChoice, reason }: DeclineData) => { + if (declineTargetId == null) return; + + setActioningId(declineTargetId); try { - await PendingModerationService.decline(module, declineTarget.id, { - reason_choice: data.reasonChoice, - ...(data.reason && { reason: data.reason }), + await PendingModerationService.decline(module, declineTargetId, { + reason_choice: reasonChoice, + ...(reason && { reason }), }); toast.success(`${itemLabel} declined`); - setEntries((prev) => prev.filter((e) => e.id !== declineTarget.entryId)); - setDeclineTarget(null); - refreshPendingCounts({ force: true }).catch(() => undefined); - } catch (err) { - toast.error(err instanceof Error ? err.message : `Failed to decline ${itemLabel}`); + setDeclineTargetId(null); + await refreshPendingData(); + } catch (error) { + toast.error(error instanceof Error ? error.message : `Failed to decline ${itemLabel}`); } finally { setActioningId(null); } }; - return ( - <> -
+ const toggleEntrySelection = (entryId: string) => { + if (isBulkActioning) return; + + setSelectedEntryIds((currentIds) => { + const nextIds = new Set(currentIds); + if (nextIds.has(entryId)) { + nextIds.delete(entryId); + } else { + nextIds.add(entryId); + } + return nextIds; + }); + }; + + const toggleAllLoadedEntries = () => { + if (isBulkActioning) return; + + setSelectedEntryIds((currentIds) => { + if (allLoadedEntriesSelected) return new Set(); + + const nextIds = new Set(currentIds); + selectableEntries.forEach((entry) => nextIds.add(entry.entryId)); + return nextIds; + }); + }; + + const clearSelection = () => setSelectedEntryIds(new Set()); + + const moderateSelectedEntries = async (action: ModerationAction, declineData?: DeclineData) => { + if (selectedEntries.length === 0) return; + + const entriesToModerate = selectedEntries; + setBulkAction(action); + setBulkProcessedCount(0); + setBulkTotalCount(entriesToModerate.length); + + try { + const succeededEntryIds = await runModerationBatch({ + entries: entriesToModerate, + onProgress: () => setBulkProcessedCount((count) => count + 1), + moderate: (entry) => { + if (action === 'approve') { + return PendingModerationService.approve(module, entry.contentId); + } + + if (!declineData) { + return Promise.reject(new Error('Decline reason is required')); + } + + return PendingModerationService.decline(module, entry.contentId, { + reason_choice: declineData.reasonChoice, + ...(declineData.reason && { reason: declineData.reason }), + }); + }, + }); + + const failureCount = entriesToModerate.length - succeededEntryIds.size; + const actionPastTense = action === 'approve' ? 'approved' : 'declined'; + + if (failureCount === 0) { + toast.success(`${entriesToModerate.length} ${tabLabel.toLowerCase()} ${actionPastTense}`); + clearSelection(); + setShowBulkDeclineModal(false); + } else { + toast.error( + `${failureCount} of ${entriesToModerate.length} ${tabLabel.toLowerCase()} failed to ${action}` + ); + setSelectedEntryIds((currentIds) => { + const nextIds = new Set(currentIds); + succeededEntryIds.forEach((entryId) => nextIds.delete(entryId)); + return nextIds; + }); + } + + await refreshPendingData(); + } finally { + setBulkAction(null); + setBulkProcessedCount(0); + setBulkTotalCount(0); + } + }; + + const renderFooter = (entry: FeedEntry) => { + const contentId = getContentId(module, entry); + if (contentId == null) return undefined; + + const isActioning = actioningId === contentId; + const isSelected = selectedEntryIds.has(entry.id); + + return ( +
+ toggleEntrySelection(entry.id)} + ariaLabel={isSelected ? `Deselect ${itemLabel}` : `Select ${itemLabel}`} + disabled={isActioning || isBulkActioning} + /> + + +
+ ); + }; + + return ( + <> + {bulkAction && ( + + )} + +
+
- {isLoading && ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
-
+ {!isLoading && selectableCount > 0 && ( +
+
+ + + {selectedCount === 0 + ? `Select all loaded (${selectableCount})` + : `${selectedCount} of ${selectableCount} loaded selected`} + +
+ + {selectedCount > 0 && ( +
+ + +
- ))} + )}
)} + {isLoading && } + {!isLoading && entries.length === 0 && (
@@ -190,47 +505,50 @@ export function PendingModerationList({ module }: Readonly { - const id = getContentId(module, entry); - const isActioning = actioningId === id; - - const footer = id ? ( -
- + entries.map((entry) => ( +
{renderFeedItem(module, entry, renderFooter(entry))}
+ ))} - -
- ) : undefined; + {isLoadingMore && } - return
{renderFeedItem(module, entry, footer)}
; - })} + {!isLoading && !isLoadingMore && !isBulkActioning && hasMore && ( +
+ )}
- {declineTarget && ( + {declineTargetId != null && ( + setDeclineTargetId(null)} + onConfirm={declineEntry} + isSubmitting={actioningId === declineTargetId} + itemLabel={itemLabel} + /> + )} + + setShowBulkApproveConfirm(false)} + onConfirm={() => moderateSelectedEntries('approve')} + title={`Approve ${selectedCount} selected ${selectedItemLabel}?`} + message={`This will approve ${selectedCount} selected ${selectedItemLabel}.`} + confirmText={`Approve ${selectedCount}`} + confirmButtonClass="bg-green-600 hover:bg-green-700" + /> + + {showBulkDeclineModal && ( setDeclineTarget(null)} - onConfirm={handleDecline} - isSubmitting={actioningId === declineTarget.id} + isOpen={showBulkDeclineModal} + onClose={() => setShowBulkDeclineModal(false)} + onConfirm={(data) => moderateSelectedEntries('decline', data)} + isSubmitting={bulkAction === 'decline'} itemLabel={itemLabel} + title={`Decline ${selectedCount} selected ${selectedItemLabel}`} + reasonPrompt={`I am declining ${ + selectedCount === 1 ? 'this' : 'these' + } ${selectedCount} selected ${selectedItemLabel} because of:`} + confirmText={`Decline ${selectedCount}`} + submittingText="Declining..." /> )} diff --git a/components/Moderators/SelectionCheckbox.tsx b/components/Moderators/SelectionCheckbox.tsx index 4283c1c3c..36a414716 100644 --- a/components/Moderators/SelectionCheckbox.tsx +++ b/components/Moderators/SelectionCheckbox.tsx @@ -7,6 +7,7 @@ interface SelectionCheckboxProps { indeterminate?: boolean; onChange: () => void; ariaLabel: string; + disabled?: boolean; } export const SelectionCheckbox: FC = ({ @@ -14,16 +15,22 @@ export const SelectionCheckbox: FC = ({ indeterminate = false, onChange, ariaLabel, + disabled = false, }) => ( + +
- -
)}
From 5bf80dfd37f373746c24424cb7983b11506d9680 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Wed, 1 Jul 2026 15:09:16 -0400 Subject: [PATCH 3/3] [Moderation] Linter Fixes --- .../Moderators/PendingModerationList.tsx | 51 ++++++++++++++--- components/Moderators/SelectionCheckbox.tsx | 55 ++++++++++++------- 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/components/Moderators/PendingModerationList.tsx b/components/Moderators/PendingModerationList.tsx index 674e28ff8..88a9ad5a9 100644 --- a/components/Moderators/PendingModerationList.tsx +++ b/components/Moderators/PendingModerationList.tsx @@ -30,6 +30,11 @@ type DeclineData = { reasonChoice: FlagReasonKey; reason: string }; type SelectablePendingEntry = { entryId: string; contentId: number }; const BULK_ACTION_BATCH_SIZE = 5; +const PENDING_MODERATION_SKELETON_KEYS = [ + 'pending-moderation-skeleton-primary', + 'pending-moderation-skeleton-secondary', + 'pending-moderation-skeleton-tertiary', +]; function getContentId(module: PendingModule, entry: FeedEntry): number | undefined { if (module === 'funding_opportunities') { @@ -159,12 +164,32 @@ function BulkProcessingOverlay({ ); } -function PendingModerationSkeleton({ count = 3 }: Readonly<{ count?: number }>) { +function getBulkActionButtonText({ + action, + activeAction, + allLoadedEntriesSelected, + selectedCount, +}: Readonly<{ + action: ModerationAction; + activeAction: ModerationAction | null; + allLoadedEntriesSelected: boolean; + selectedCount: number; +}>) { + if (activeAction === action) { + return action === 'approve' ? 'Approving...' : 'Declining...'; + } + + const actionLabel = action === 'approve' ? 'Approve' : 'Decline'; + const selectionLabel = allLoadedEntriesSelected ? 'all' : 'selected'; + return `${actionLabel} ${selectionLabel} (${selectedCount})`; +} + +function PendingModerationSkeleton() { return (
- {Array.from({ length: count }).map((_, index) => ( + {PENDING_MODERATION_SKELETON_KEYS.map((skeletonKey) => (
@@ -248,6 +273,18 @@ export function PendingModerationList({ module }: Readonly 0 && selectedCount === selectableCount; const someLoadedEntriesSelected = selectedCount > 0 && selectedCount < selectableCount; const isBulkActioning = bulkAction != null; + const approveSelectedText = getBulkActionButtonText({ + action: 'approve', + activeAction: bulkAction, + allLoadedEntriesSelected, + selectedCount, + }); + const declineSelectedText = getBulkActionButtonText({ + action: 'decline', + activeAction: bulkAction, + allLoadedEntriesSelected, + selectedCount, + }); const loadNextPage = useCallback(async () => { if (!hasMore || isLoadingMore) return; @@ -459,9 +496,7 @@ export function PendingModerationList({ module }: Readonly - {bulkAction === 'approve' - ? 'Approving...' - : `Approve ${allLoadedEntriesSelected ? 'all' : 'selected'} (${selectedCount})`} + {approveSelectedText}
-); +}) => { + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate; + } + }, [indeterminate]); + + return ( + + ); +};