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:
parent
ef39b75da1
commit
3d74edfe3c
4 changed files with 149 additions and 11 deletions
|
|
@ -7,6 +7,8 @@ import {
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
|
import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,6 +31,9 @@ import { ColDef, RowClickedEvent } from "ag-grid-community";
|
||||||
import "@/components/AgGrid/registerAgGridModules";
|
import "@/components/AgGrid/registerAgGridModules";
|
||||||
import "@/components/AgGrid/agGridStyles.css";
|
import "@/components/AgGrid/agGridStyles.css";
|
||||||
import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown";
|
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 to get the appropriate icon for a connector type
|
||||||
function getSourceIcon(connectorType?: string) {
|
function getSourceIcon(connectorType?: string) {
|
||||||
|
|
@ -52,6 +57,10 @@ function SearchPage() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [queryInputText, setQueryInputText] = useState("");
|
const [queryInputText, setQueryInputText] = useState("");
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<File[]>([]);
|
||||||
|
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
const deleteDocumentMutation = useDeleteDocument();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
|
|
@ -86,9 +95,11 @@ function SearchPage() {
|
||||||
{
|
{
|
||||||
field: "filename",
|
field: "filename",
|
||||||
headerName: "Source",
|
headerName: "Source",
|
||||||
|
checkboxSelection: true,
|
||||||
|
headerCheckboxSelection: true,
|
||||||
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
|
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 ml-2">
|
||||||
{getSourceIcon(data?.connector_type)}
|
{getSourceIcon(data?.connector_type)}
|
||||||
<span className="font-medium text-foreground truncate">
|
<span className="font-medium text-foreground truncate">
|
||||||
{value}
|
{value}
|
||||||
|
|
@ -96,6 +107,10 @@ function SearchPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
cellStyle: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "size",
|
field: "size",
|
||||||
|
|
@ -163,6 +178,52 @@ function SearchPage() {
|
||||||
suppressMovable: true,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${
|
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>
|
<h2 className="text-lg font-semibold">Project Knowledge</h2>
|
||||||
<KnowledgeDropdown variant="button" />
|
<KnowledgeDropdown variant="button" />
|
||||||
</div>
|
</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 */}
|
{/* Search Input Area */}
|
||||||
<div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]">
|
<div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]">
|
||||||
<form onSubmit={handleSearch} className="flex gap-3">
|
<form onSubmit={handleSearch} className="flex gap-3">
|
||||||
|
|
@ -259,8 +347,15 @@ function SearchPage() {
|
||||||
loading={isFetching}
|
loading={isFetching}
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
rowData={fileResults}
|
rowData={fileResults}
|
||||||
|
rowSelection="multiple"
|
||||||
|
suppressRowClickSelection={true}
|
||||||
|
getRowId={params => params.data.filename}
|
||||||
|
onSelectionChanged={onSelectionChanged}
|
||||||
onRowClicked={(params: RowClickedEvent<File>) => {
|
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={() => (
|
noRowsOverlayComponent={() => (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -276,6 +371,24 @@ function SearchPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ body {
|
||||||
--ag-wrapper-border: none;
|
--ag-wrapper-border: none;
|
||||||
--ag-font-family: var(--font-sans);
|
--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 {
|
.ag-header {
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
@ -18,4 +24,20 @@ body {
|
||||||
.ag-row {
|
.ag-row {
|
||||||
cursor: pointer;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
DateFilterModule,
|
DateFilterModule,
|
||||||
EventApiModule,
|
EventApiModule,
|
||||||
GridStateModule,
|
GridStateModule,
|
||||||
|
RowSelectionModule,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
|
||||||
// Importing necessary modules from ag-grid-community
|
// Importing necessary modules from ag-grid-community
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
DateFilterModule,
|
DateFilterModule,
|
||||||
EventApiModule,
|
EventApiModule,
|
||||||
GridStateModule,
|
GridStateModule,
|
||||||
|
RowSelectionModule,
|
||||||
// The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
|
// The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
|
||||||
...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
|
...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ INDEX_BODY = {
|
||||||
"type": "knn_vector",
|
"type": "knn_vector",
|
||||||
"dimension": VECTOR_DIM,
|
"dimension": VECTOR_DIM,
|
||||||
"method": {
|
"method": {
|
||||||
"name": "disk_ann",
|
"name": "hnsw",
|
||||||
"engine": "jvector",
|
"engine": "lucene",
|
||||||
"space_type": "l2",
|
"space_type": "l2",
|
||||||
"parameters": {"ef_construction": 100, "m": 16},
|
"parameters": {"ef_construction": 100, "m": 16},
|
||||||
},
|
},
|
||||||
|
|
@ -255,8 +255,8 @@ class AppClients:
|
||||||
self.opensearch = AsyncOpenSearch(
|
self.opensearch = AsyncOpenSearch(
|
||||||
hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}],
|
hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}],
|
||||||
connection_class=AIOHttpConnection,
|
connection_class=AIOHttpConnection,
|
||||||
scheme="https",
|
scheme="http",
|
||||||
use_ssl=True,
|
use_ssl=False,
|
||||||
verify_certs=False,
|
verify_certs=False,
|
||||||
ssl_assert_fingerprint=None,
|
ssl_assert_fingerprint=None,
|
||||||
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD),
|
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD),
|
||||||
|
|
@ -381,17 +381,18 @@ class AppClients:
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_user_opensearch_client(self, jwt_token: str):
|
def create_user_opensearch_client(self, jwt_token: str):
|
||||||
"""Create OpenSearch client with user's JWT token for OIDC auth"""
|
"""Create OpenSearch client with basic auth (JWT not used in current setup)"""
|
||||||
headers = {"Authorization": f"Bearer {jwt_token}"}
|
# Note: jwt_token parameter kept for compatibility but not used
|
||||||
|
# Using basic auth instead of JWT Bearer tokens
|
||||||
|
|
||||||
return AsyncOpenSearch(
|
return AsyncOpenSearch(
|
||||||
hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}],
|
hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}],
|
||||||
connection_class=AIOHttpConnection,
|
connection_class=AIOHttpConnection,
|
||||||
scheme="https",
|
scheme="http",
|
||||||
use_ssl=True,
|
use_ssl=False,
|
||||||
verify_certs=False,
|
verify_certs=False,
|
||||||
ssl_assert_fingerprint=None,
|
ssl_assert_fingerprint=None,
|
||||||
headers=headers,
|
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), # Use basic auth
|
||||||
http_compress=True,
|
http_compress=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue