Enhance Knowledge Page with Bulk Document Deletion

- Implemented bulk selection and deletion functionality in the Knowledge page.
- Added a confirmation dialog for bulk deletions, providing user feedback on success or failure.
- Updated AgGrid configuration to support multiple row selection and checkbox functionality.
- Styled checkboxes for better visibility and user experience.
- Refactored related components to accommodate new bulk actions.
This commit is contained in:
Deon Sanchez 2025-09-17 17:31:51 -06:00
parent ef39b75da1
commit 3d74edfe3c
4 changed files with 149 additions and 11 deletions

View file

@ -7,6 +7,8 @@ import {
HardDrive,
Loader2,
Search,
Trash2,
X,
} from "lucide-react";
import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
import {
@ -29,6 +31,9 @@ import { ColDef, RowClickedEvent } from "ag-grid-community";
import "@/components/AgGrid/registerAgGridModules";
import "@/components/AgGrid/agGridStyles.css";
import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown";
import { DeleteConfirmationDialog } from "../../../components/confirmation-dialog";
import { useDeleteDocument } from "../api/mutations/useDeleteDocument";
import { toast } from "sonner";
// Function to get the appropriate icon for a connector type
function getSourceIcon(connectorType?: string) {
@ -52,6 +57,10 @@ function SearchPage() {
const [query, setQuery] = useState("");
const [queryInputText, setQueryInputText] = useState("");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [selectedRows, setSelectedRows] = useState<File[]>([]);
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
const deleteDocumentMutation = useDeleteDocument();
const {
data = [],
@ -86,9 +95,11 @@ function SearchPage() {
{
field: "filename",
headerName: "Source",
checkboxSelection: true,
headerCheckboxSelection: true,
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 ml-2">
{getSourceIcon(data?.connector_type)}
<span className="font-medium text-foreground truncate">
{value}
@ -96,6 +107,10 @@ function SearchPage() {
</div>
);
},
cellStyle: {
display: "flex",
alignItems: "center",
},
},
{
field: "size",
@ -163,6 +178,52 @@ function SearchPage() {
suppressMovable: true,
};
const onSelectionChanged = useCallback(() => {
if (gridRef.current) {
const selectedNodes = gridRef.current.api.getSelectedRows();
setSelectedRows(selectedNodes);
}
}, []);
const handleBulkDelete = async () => {
if (selectedRows.length === 0) return;
try {
// Delete each file individually since the API expects one filename at a time
const deletePromises = selectedRows.map(row =>
deleteDocumentMutation.mutateAsync({ filename: row.filename })
);
await Promise.all(deletePromises);
toast.success(
`Successfully deleted ${selectedRows.length} document${
selectedRows.length > 1 ? "s" : ""
}`
);
setSelectedRows([]);
setShowBulkDeleteDialog(false);
// Clear selection in the grid
if (gridRef.current) {
gridRef.current.api.deselectAll();
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to delete some documents"
);
}
};
const clearSelection = useCallback(() => {
setSelectedRows([]);
if (gridRef.current) {
gridRef.current.api.deselectAll();
}
}, []);
return (
<div
className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${
@ -183,6 +244,33 @@ function SearchPage() {
<h2 className="text-lg font-semibold">Project Knowledge</h2>
<KnowledgeDropdown variant="button" />
</div>
{/* Bulk Actions Bar */}
{selectedRows.length > 0 && (
<div className="flex items-center justify-between bg-muted/20 rounded-lg border border-border/50 px-4 py-3 mb-6">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">
{selectedRows.length} document
{selectedRows.length > 1 ? "s" : ""} selected
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkDeleteDialog(true)}
className="text-destructive border-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Selected
</Button>
<Button variant="ghost" size="sm" onClick={clearSelection}>
<X className="h-4 w-4 mr-2" />
Clear Selection
</Button>
</div>
</div>
)}
{/* Search Input Area */}
<div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]">
<form onSubmit={handleSearch} className="flex gap-3">
@ -259,8 +347,15 @@ function SearchPage() {
loading={isFetching}
ref={gridRef}
rowData={fileResults}
rowSelection="multiple"
suppressRowClickSelection={true}
getRowId={params => params.data.filename}
onSelectionChanged={onSelectionChanged}
onRowClicked={(params: RowClickedEvent<File>) => {
setSelectedFile(params.data?.filename ?? "");
// Only navigate to chunks if no rows are selected and not clicking on checkbox
if (selectedRows.length === 0) {
setSelectedFile(params.data?.filename ?? "");
}
}}
noRowsOverlayComponent={() => (
<div className="text-center">
@ -276,6 +371,24 @@ function SearchPage() {
/>
)}
</div>
{/* Bulk Delete Confirmation Dialog */}
<DeleteConfirmationDialog
open={showBulkDeleteDialog}
onOpenChange={setShowBulkDeleteDialog}
title="Delete Documents"
description={`Are you sure you want to delete ${
selectedRows.length
} document${
selectedRows.length > 1 ? "s" : ""
}? This will remove all chunks and data associated with these documents. This action cannot be undone.
Documents to be deleted:
${selectedRows.map(row => `${row.filename}`).join("\n")}`}
confirmText="Delete All"
onConfirm={handleBulkDelete}
isLoading={deleteDocumentMutation.isPending}
/>
</div>
);
}

View file

@ -11,6 +11,12 @@ body {
--ag-wrapper-border: none;
--ag-font-family: var(--font-sans);
/* Checkbox styling */
--ag-checkbox-background-color: hsl(var(--background));
--ag-checkbox-border-color: hsl(var(--border));
--ag-checkbox-checked-color: hsl(var(--primary));
--ag-checkbox-unchecked-color: transparent;
.ag-header {
border-bottom: 1px solid hsl(var(--border));
margin-bottom: 0.5rem;
@ -18,4 +24,20 @@ body {
.ag-row {
cursor: pointer;
}
/* Make sure checkboxes are visible */
.ag-selection-checkbox,
.ag-header-select-all {
opacity: 1 !important;
}
.ag-checkbox-input-wrapper {
border: 1px solid hsl(var(--border));
background-color: hsl(var(--background));
}
.ag-checkbox-input-wrapper.ag-checked {
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
}
}

View file

@ -11,6 +11,7 @@ import {
DateFilterModule,
EventApiModule,
GridStateModule,
RowSelectionModule,
} from 'ag-grid-community';
// Importing necessary modules from ag-grid-community
@ -27,6 +28,7 @@ import {
DateFilterModule,
EventApiModule,
GridStateModule,
RowSelectionModule,
// The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
]);

View file

@ -89,8 +89,8 @@ INDEX_BODY = {
"type": "knn_vector",
"dimension": VECTOR_DIM,
"method": {
"name": "disk_ann",
"engine": "jvector",
"name": "hnsw",
"engine": "lucene",
"space_type": "l2",
"parameters": {"ef_construction": 100, "m": 16},
},
@ -255,8 +255,8 @@ class AppClients:
self.opensearch = AsyncOpenSearch(
hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}],
connection_class=AIOHttpConnection,
scheme="https",
use_ssl=True,
scheme="http",
use_ssl=False,
verify_certs=False,
ssl_assert_fingerprint=None,
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD),
@ -381,17 +381,18 @@ class AppClients:
)
def create_user_opensearch_client(self, jwt_token: str):
"""Create OpenSearch client with user's JWT token for OIDC auth"""
headers = {"Authorization": f"Bearer {jwt_token}"}
"""Create OpenSearch client with basic auth (JWT not used in current setup)"""
# Note: jwt_token parameter kept for compatibility but not used
# Using basic auth instead of JWT Bearer tokens
return AsyncOpenSearch(
hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}],
connection_class=AIOHttpConnection,
scheme="https",
use_ssl=True,
scheme="http",
use_ssl=False,
verify_certs=False,
ssl_assert_fingerprint=None,
headers=headers,
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), # Use basic auth
http_compress=True,
)