mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
feat: fixed connectors dialog navigation, Implement bulk document deletion and improve search space filtering
- Added bulk delete functionality for documents in DocumentsTableShell and DocumentsSidebar. - Enhanced search space retrieval to exclude spaces marked for deletion in read_search_spaces. - Updated connector dialog to synchronize URL parameters when opened externally. - Improved layout behavior to handle search space deletion and redirection more effectively.
This commit is contained in:
parent
7c3aedf811
commit
d61e29e74b
7 changed files with 312 additions and 97 deletions
|
|
@ -125,11 +125,14 @@ async def read_search_spaces(
|
|||
If False (default), return all search spaces the user has access to.
|
||||
"""
|
||||
try:
|
||||
# Exclude spaces that are pending background deletion
|
||||
not_deleting = ~SearchSpace.name.startswith("[DELETING] ")
|
||||
|
||||
if owned_only:
|
||||
# Return only search spaces where user is the original creator (user_id)
|
||||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.filter(SearchSpace.user_id == user.id, not_deleting)
|
||||
.order_by(SearchSpace.id.asc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
|
|
@ -139,7 +142,7 @@ async def read_search_spaces(
|
|||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
.filter(SearchSpaceMembership.user_id == user.id, not_deleting)
|
||||
.order_by(SearchSpace.id.asc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ export function DocumentsTableShell({
|
|||
sortDesc,
|
||||
onSortChange,
|
||||
deleteDocument,
|
||||
bulkDeleteDocuments,
|
||||
searchSpaceId,
|
||||
hasMore = false,
|
||||
loadingMore = false,
|
||||
|
|
@ -336,6 +337,7 @@ export function DocumentsTableShell({
|
|||
sortDesc: boolean;
|
||||
onSortChange: (key: SortKey) => void;
|
||||
deleteDocument: (id: number) => Promise<boolean>;
|
||||
bulkDeleteDocuments?: (ids: number[]) => Promise<{ success: number; failed: number }>;
|
||||
searchSpaceId: string;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
|
|
@ -370,6 +372,8 @@ export function DocumentsTableShell({
|
|||
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null);
|
||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const desktopSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -496,45 +500,119 @@ export function DocumentsTableShell({
|
|||
|
||||
const onSortHeader = (key: SortKey) => onSortChange(key);
|
||||
|
||||
const deletableSelectedIds = React.useMemo(() => {
|
||||
if (!mentionedDocIds || mentionedDocIds.size === 0) return [];
|
||||
return sorted
|
||||
.filter((doc) => {
|
||||
if (!mentionedDocIds.has(doc.id)) return false;
|
||||
const state = doc.status?.state;
|
||||
return (
|
||||
state !== "pending" &&
|
||||
state !== "processing" &&
|
||||
!NON_DELETABLE_DOCUMENT_TYPES.includes(
|
||||
doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
||||
)
|
||||
);
|
||||
})
|
||||
.map((doc) => doc.id);
|
||||
}, [sorted, mentionedDocIds]);
|
||||
|
||||
const hasDeletableSelection = deletableSelectedIds.length > 0;
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
if (deletableSelectedIds.length === 0) return;
|
||||
setIsBulkDeleting(true);
|
||||
try {
|
||||
if (bulkDeleteDocuments) {
|
||||
const { success, failed } = await bulkDeleteDocuments(deletableSelectedIds);
|
||||
if (success > 0) {
|
||||
toast.success(`Deleted ${success} document${success !== 1 ? "s" : ""}`);
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(`Failed to delete ${failed} document${failed !== 1 ? "s" : ""}`);
|
||||
}
|
||||
} else {
|
||||
const results = await Promise.allSettled(
|
||||
deletableSelectedIds.map((id) => deleteDocument(id))
|
||||
);
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value === true
|
||||
).length;
|
||||
const failCount = deletableSelectedIds.length - successCount;
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
`Deleted ${successCount} document${successCount !== 1 ? "s" : ""}`
|
||||
);
|
||||
}
|
||||
if (failCount > 0) {
|
||||
toast.error(
|
||||
`Failed to delete ${failCount} document${failCount !== 1 ? "s" : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete documents");
|
||||
}
|
||||
setIsBulkDeleting(false);
|
||||
setBulkDeleteConfirmOpen(false);
|
||||
}, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]);
|
||||
|
||||
return (
|
||||
<div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0">
|
||||
{/* Desktop Table View */}
|
||||
<div className="hidden md:flex md:flex-col flex-1 min-h-0">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/50">
|
||||
<TableHead className="w-10 pl-3 pr-0 text-center h-8">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Checkbox
|
||||
checked={allMentionedOnPage || (someMentionedOnPage && "indeterminate")}
|
||||
onCheckedChange={(v) => toggleAll(!!v)}
|
||||
aria-label={hasChatMode ? "Toggle all for chat" : "Select all"}
|
||||
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-8 px-2">
|
||||
<SortableHeader
|
||||
sortKey="title"
|
||||
currentSortKey={sortKey}
|
||||
sortDesc={sortDesc}
|
||||
onSort={onSortHeader}
|
||||
icon={<FileText size={14} className="text-muted-foreground" />}
|
||||
>
|
||||
Document
|
||||
</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="w-10 text-center h-8 px-0">
|
||||
<span className="flex items-center justify-center">
|
||||
<Network size={14} className="text-muted-foreground" />
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-12 text-center h-8 pl-0 pr-3">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/50">
|
||||
<TableHead className="w-10 pl-3 pr-0 text-center h-8">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Checkbox
|
||||
checked={allMentionedOnPage || (someMentionedOnPage && "indeterminate")}
|
||||
onCheckedChange={(v) => toggleAll(!!v)}
|
||||
aria-label={hasChatMode ? "Toggle all for chat" : "Select all"}
|
||||
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-8 px-2">
|
||||
<SortableHeader
|
||||
sortKey="title"
|
||||
currentSortKey={sortKey}
|
||||
sortDesc={sortDesc}
|
||||
onSort={onSortHeader}
|
||||
icon={<FileText size={14} className="text-muted-foreground" />}
|
||||
>
|
||||
Document
|
||||
</SortableHeader>
|
||||
</TableHead>
|
||||
<TableHead className="w-10 text-center h-8 px-0">
|
||||
<span className="flex items-center justify-center">
|
||||
<Network size={14} className="text-muted-foreground" />
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-12 text-center h-8 pl-0 pr-3">
|
||||
{hasDeletableSelection ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkDeleteConfirmOpen(true)}
|
||||
className="inline-flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Delete {deletableSelectedIds.length} selected
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-muted-foreground">Status</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
)}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
{loading ? (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table className="table-fixed w-full">
|
||||
|
|
@ -605,50 +683,50 @@ export function DocumentsTableShell({
|
|||
<div ref={desktopScrollRef} className="flex-1 overflow-auto">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableBody>
|
||||
{sorted.map((doc) => {
|
||||
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
||||
const canInteract = isSelectable(doc);
|
||||
const handleRowToggle = () => {
|
||||
if (canInteract && onToggleChatMention) {
|
||||
onToggleChatMention(doc, isMentioned);
|
||||
}
|
||||
};
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleViewMetadata(doc);
|
||||
return;
|
||||
}
|
||||
handleRowToggle();
|
||||
};
|
||||
return (
|
||||
<RowContextMenu
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onPreview={handleViewDocument}
|
||||
onDelete={setDeleteDoc}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onEditNavigate={onEditNavigate}
|
||||
{sorted.map((doc) => {
|
||||
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
||||
const canInteract = isSelectable(doc);
|
||||
const handleRowToggle = () => {
|
||||
if (canInteract && onToggleChatMention) {
|
||||
onToggleChatMention(doc, isMentioned);
|
||||
}
|
||||
};
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleViewMetadata(doc);
|
||||
return;
|
||||
}
|
||||
handleRowToggle();
|
||||
};
|
||||
return (
|
||||
<RowContextMenu
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onPreview={handleViewDocument}
|
||||
onDelete={setDeleteDoc}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onEditNavigate={onEditNavigate}
|
||||
>
|
||||
<tr
|
||||
className={`border-b border-border/50 transition-colors ${
|
||||
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
|
||||
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<tr
|
||||
className={`border-b border-border/50 transition-colors ${
|
||||
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
|
||||
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<TableCell
|
||||
className="w-10 pl-3 pr-0 py-1.5 text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
onCheckedChange={() => handleRowToggle()}
|
||||
disabled={!canInteract}
|
||||
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
||||
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
onCheckedChange={() => handleRowToggle()}
|
||||
disabled={!canInteract}
|
||||
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
||||
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1.5 max-w-0">
|
||||
|
|
@ -742,6 +820,22 @@ export function DocumentsTableShell({
|
|||
ref={mobileScrollRef}
|
||||
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
|
||||
>
|
||||
{hasDeletableSelection && (
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50 sticky top-0 z-10">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{deletableSelectedIds.length} deletable selected
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => setBulkDeleteConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{sorted.map((doc) => {
|
||||
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
|
||||
const canInteract = isSelectable(doc);
|
||||
|
|
@ -957,6 +1051,41 @@ export function DocumentsTableShell({
|
|||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{/* Bulk Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={bulkDeleteConfirmOpen}
|
||||
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete {deletableSelectedIds.length} document
|
||||
{deletableSelectedIds.length !== 1 ? "s" : ""}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone.{" "}
|
||||
{deletableSelectedIds.length === 1
|
||||
? "This document"
|
||||
: `These ${deletableSelectedIds.length} documents`}{" "}
|
||||
will be permanently deleted from your search space.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isBulkDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleBulkDelete();
|
||||
}}
|
||||
disabled={isBulkDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isBulkDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,7 +258,10 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
</TooltipIconButton>
|
||||
)}
|
||||
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
||||
<DialogContent
|
||||
onFocusOutside={(e) => e.preventDefault()}
|
||||
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
|
|
|
|||
|
|
@ -181,6 +181,24 @@ export const useConnectorDialog = () => {
|
|||
[searchSpaceId, indexConnector, updateConnector, refetchAllConnectors]
|
||||
);
|
||||
|
||||
// When the dialog is opened externally (via setConnectorDialogOpen atom from
|
||||
// thread.tsx / DocumentsSidebar.tsx), the URL is not updated. Sync it here
|
||||
// so that other handlers that read window.location.href see modal=connectors.
|
||||
const activeTabRef = useRef(activeTab);
|
||||
activeTabRef.current = activeTab;
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const url = new URL(window.location.href);
|
||||
const modalParam = url.searchParams.get("modal");
|
||||
const tabParam = url.searchParams.get("tab");
|
||||
if (modalParam !== "connectors" || (tabParam !== "all" && tabParam !== "active")) {
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", activeTabRef.current);
|
||||
window.history.replaceState({ modal: true }, "", url.toString());
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Synchronize state with URL query params
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -1647,12 +1665,13 @@ export const useConnectorDialog = () => {
|
|||
[activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen]
|
||||
);
|
||||
|
||||
// Handle tab change
|
||||
// Handle tab change — only update React state.
|
||||
// Avoid window.history.replaceState here: Next.js intercepts it, triggers a
|
||||
// searchParams update/transition, and the resulting concurrent re-render can
|
||||
// cause Radix Dialog's DismissableLayer to detect a transient focus-outside
|
||||
// event, which fires onOpenChange(false) and closes the dialog.
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", value);
|
||||
window.history.replaceState({ modal: true }, "", url.toString());
|
||||
}, []);
|
||||
|
||||
// Handle scroll
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
|
||||
// Atoms
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces, isSuccess: searchSpacesLoaded } = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||
|
|
@ -276,6 +276,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
|
||||
}, [searchSpaceId, searchSpaces]);
|
||||
|
||||
// Safety redirect: if the current search space is no longer in the user's list
|
||||
// (e.g. deleted by background task, membership revoked), redirect to a valid space.
|
||||
useEffect(() => {
|
||||
if (!searchSpacesLoaded || !searchSpaceId || isDeletingSearchSpace || isLeavingSearchSpace) return;
|
||||
if (searchSpaces.length > 0 && !activeSearchSpace) {
|
||||
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
|
||||
} else if (searchSpaces.length === 0 && searchSpacesLoaded) {
|
||||
router.replace("/dashboard");
|
||||
}
|
||||
}, [searchSpacesLoaded, searchSpaceId, searchSpaces, activeSearchSpace, isDeletingSearchSpace, isLeavingSearchSpace, router]);
|
||||
|
||||
// Transform and split chats into private and shared based on visibility
|
||||
const { myChats, sharedChats } = useMemo(() => {
|
||||
if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
|
||||
|
|
@ -384,17 +395,27 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setIsDeletingSearchSpace(true);
|
||||
try {
|
||||
await deleteSearchSpace({ id: searchSpaceToDelete.id });
|
||||
refetchSearchSpaces();
|
||||
if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) {
|
||||
const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id);
|
||||
if (remaining.length > 0) {
|
||||
router.push(`/dashboard/${remaining[0].id}/new-chat`);
|
||||
|
||||
const isCurrentSpace = Number(searchSpaceId) === searchSpaceToDelete.id;
|
||||
|
||||
// Await refetch so we have the freshest list (backend now hides [DELETING] spaces)
|
||||
const result = await refetchSearchSpaces();
|
||||
const updatedSpaces = (result.data ?? []).filter(
|
||||
(s) => s.id !== searchSpaceToDelete.id
|
||||
);
|
||||
|
||||
if (isCurrentSpace) {
|
||||
if (updatedSpaces.length > 0) {
|
||||
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} else if (searchSpaces.length === 1) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting search space:", error);
|
||||
toast.error(
|
||||
t.has("delete_space_error") ? t("delete_space_error") : "Failed to delete search space"
|
||||
);
|
||||
} finally {
|
||||
setIsDeletingSearchSpace(false);
|
||||
setShowDeleteSearchSpaceDialog(false);
|
||||
|
|
@ -405,8 +426,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
deleteSearchSpace,
|
||||
refetchSearchSpaces,
|
||||
searchSpaceId,
|
||||
searchSpaces,
|
||||
router,
|
||||
t,
|
||||
]);
|
||||
|
||||
const confirmLeaveSearchSpace = useCallback(async () => {
|
||||
|
|
@ -414,23 +435,30 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setIsLeavingSearchSpace(true);
|
||||
try {
|
||||
await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id);
|
||||
refetchSearchSpaces();
|
||||
if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) {
|
||||
const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id);
|
||||
if (remaining.length > 0) {
|
||||
router.push(`/dashboard/${remaining[0].id}/new-chat`);
|
||||
|
||||
const isCurrentSpace = Number(searchSpaceId) === searchSpaceToLeave.id;
|
||||
|
||||
const result = await refetchSearchSpaces();
|
||||
const updatedSpaces = (result.data ?? []).filter(
|
||||
(s) => s.id !== searchSpaceToLeave.id
|
||||
);
|
||||
|
||||
if (isCurrentSpace) {
|
||||
if (updatedSpaces.length > 0) {
|
||||
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} else if (searchSpaces.length === 1) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error leaving search space:", error);
|
||||
toast.error(t.has("leave_error") ? t("leave_error") : "Failed to leave search space");
|
||||
} finally {
|
||||
setIsLeavingSearchSpace(false);
|
||||
setShowLeaveSearchSpaceDialog(false);
|
||||
setSearchSpaceToLeave(null);
|
||||
}
|
||||
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]);
|
||||
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, router, t]);
|
||||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export function DocumentsSidebar({
|
|||
loadingMore: realtimeLoadingMore,
|
||||
hasMore: realtimeHasMore,
|
||||
loadMore: realtimeLoadMore,
|
||||
removeItems: realtimeRemoveItems,
|
||||
error: realtimeError,
|
||||
} = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
|
||||
|
||||
|
|
@ -137,6 +138,7 @@ export function DocumentsSidebar({
|
|||
await deleteDocumentMutation({ id });
|
||||
toast.success(t("delete_success") || "Document deleted");
|
||||
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
|
||||
realtimeRemoveItems([id]);
|
||||
if (isSearchMode) {
|
||||
searchRemoveItems([id]);
|
||||
}
|
||||
|
|
@ -146,7 +148,30 @@ export function DocumentsSidebar({
|
|||
return false;
|
||||
}
|
||||
},
|
||||
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs]
|
||||
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, realtimeRemoveItems, setSidebarDocs]
|
||||
);
|
||||
|
||||
const handleBulkDeleteDocuments = useCallback(
|
||||
async (ids: number[]): Promise<{ success: number; failed: number }> => {
|
||||
const successIds: number[] = [];
|
||||
const results = await Promise.allSettled(
|
||||
ids.map(async (id) => {
|
||||
await deleteDocumentMutation({ id });
|
||||
successIds.push(id);
|
||||
})
|
||||
);
|
||||
if (successIds.length > 0) {
|
||||
setSidebarDocs((prev) => prev.filter((d) => !successIds.includes(d.id)));
|
||||
realtimeRemoveItems(successIds);
|
||||
if (isSearchMode) {
|
||||
searchRemoveItems(successIds);
|
||||
}
|
||||
}
|
||||
const success = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
return { success, failed };
|
||||
},
|
||||
[deleteDocumentMutation, isSearchMode, searchRemoveItems, realtimeRemoveItems, setSidebarDocs]
|
||||
);
|
||||
|
||||
const sortKeyRef = useRef(sortKey);
|
||||
|
|
@ -319,6 +344,7 @@ export function DocumentsSidebar({
|
|||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
deleteDocument={handleDeleteDocument}
|
||||
bulkDeleteDocuments={handleBulkDeleteDocuments}
|
||||
searchSpaceId={String(searchSpaceId)}
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
|
|
|
|||
|
|
@ -490,6 +490,12 @@ export function useDocuments(
|
|||
apiToDisplayDoc,
|
||||
]);
|
||||
|
||||
const removeItems = useCallback((ids: number[]) => {
|
||||
const idSet = new Set(ids);
|
||||
setDocuments((prev) => prev.filter((item) => !idSet.has(item.id)));
|
||||
setTotal((prev) => Math.max(0, prev - ids.length));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documents,
|
||||
typeCounts,
|
||||
|
|
@ -498,6 +504,7 @@ export function useDocuments(
|
|||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
removeItems,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue