From 8749225661ee9b8a00b44731901437597e768634 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:36:28 +0530 Subject: [PATCH 01/28] fix: changed design for tooltip --- surfsense_web/components/assistant-ui/attachment.tsx | 5 ++++- .../components/assistant-ui/tooltip-icon-button.tsx | 7 ++++++- surfsense_web/components/ui/tooltip.tsx | 5 ++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/surfsense_web/components/assistant-ui/attachment.tsx b/surfsense_web/components/assistant-ui/attachment.tsx index 9750b24d9..7fce93321 100644 --- a/surfsense_web/components/assistant-ui/attachment.tsx +++ b/surfsense_web/components/assistant-ui/attachment.tsx @@ -200,7 +200,10 @@ const AttachmentUI: FC = () => { {isComposer && !isProcessing && } - + {isProcessing ? ( diff --git a/surfsense_web/components/assistant-ui/tooltip-icon-button.tsx b/surfsense_web/components/assistant-ui/tooltip-icon-button.tsx index 154240cb4..b415acd13 100644 --- a/surfsense_web/components/assistant-ui/tooltip-icon-button.tsx +++ b/surfsense_web/components/assistant-ui/tooltip-icon-button.tsx @@ -27,7 +27,12 @@ export const TooltipIconButton = forwardRef{tooltip} - {tooltip} + + {tooltip} + ); } diff --git a/surfsense_web/components/ui/tooltip.tsx b/surfsense_web/components/ui/tooltip.tsx index 98420c858..e1aa458e6 100644 --- a/surfsense_web/components/ui/tooltip.tsx +++ b/surfsense_web/components/ui/tooltip.tsx @@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps) { @@ -42,13 +42,12 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground border shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance", + "bg-popover text-popover-foreground fill-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance", className )} {...props} > {children} - ); From 9f19bea28496e43d7ea0d1fe66ada8b9481cd0df Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 04:03:34 +0530 Subject: [PATCH 02/28] feat: Extract connector indicator UI from thread into a new dedicated component. --- .../assistant-ui/connector-popup.tsx | 163 ++++++++++++++++++ .../components/assistant-ui/thread.tsx | 150 +--------------- 2 files changed, 164 insertions(+), 149 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx new file mode 100644 index 000000000..fbe137287 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -0,0 +1,163 @@ +import { useAtomValue } from "jotai"; +import { + ChevronRightIcon, + Loader2, + Plug2, + Plus, +} from "lucide-react"; +import Link from "next/link"; +import { + type FC, + useCallback, + useRef, + useState, +} from "react"; +import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; +import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; +import { cn } from "@/lib/utils"; + +export const ConnectorIndicator: FC = () => { + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors( + false, + searchSpaceId ? Number(searchSpaceId) : undefined + ); + const { data: documentTypeCounts, isLoading: documentTypesLoading } = + useAtomValue(documentTypeCountsAtom); + const [isOpen, setIsOpen] = useState(false); + const closeTimeoutRef = useRef(null); + + const isLoading = connectorsLoading || documentTypesLoading; + + // Get document types that have documents in the search space + const activeDocumentTypes = documentTypeCounts + ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0) + : []; + + const hasConnectors = connectors.length > 0; + const hasSources = hasConnectors || activeDocumentTypes.length > 0; + const totalSourceCount = connectors.length + activeDocumentTypes.length; + + const handleMouseEnter = useCallback(() => { + // Clear any pending close timeout + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + setIsOpen(true); + }, []); + + const handleMouseLeave = useCallback(() => { + // Delay closing by 150ms for better UX + closeTimeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, 150); + }, []); + + if (!searchSpaceId) return null; + + return ( + + + + + + {hasSources ? ( +
+
+

Connected Sources

+ + {totalSourceCount} + +
+
+ {/* Document types from the search space */} + {activeDocumentTypes.map(([docType]) => ( +
+ {getConnectorIcon(docType, "size-3.5")} + {getDocumentTypeLabel(docType)} +
+ ))} + {/* Search source connectors */} + {connectors.map((connector) => ( +
+ {getConnectorIcon(connector.connector_type, "size-3.5")} + {connector.name} +
+ ))} +
+
+ + + Add more sources + + +
+
+ ) : ( +
+

No sources yet

+

+ Add documents or connect data sources to enhance search results. +

+ + + Add Connector + +
+ )} +
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index cb01e7605..1023c4e18 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -23,8 +23,6 @@ import { FileText, Loader2, PencilIcon, - Plug2, - Plus, RefreshCwIcon, SquareIcon, } from "lucide-react"; @@ -41,25 +39,23 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, messageDocumentsMapAtom, } from "@/atoms/chat/mentioned-documents.atom"; -import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { globalNewLLMConfigsAtom, llmPreferencesAtom, newLLMConfigsAtom, } from "@/atoms/new-llm-config/new-llm-config-query.atoms"; -import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments, } from "@/components/assistant-ui/attachment"; +import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { InlineMentionEditor, type InlineMentionEditorRef, @@ -75,10 +71,7 @@ import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; -import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { cn } from "@/lib/utils"; /** @@ -625,147 +618,6 @@ const Composer: FC = () => { ); }; -const ConnectorIndicator: FC = () => { - const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors( - false, - searchSpaceId ? Number(searchSpaceId) : undefined - ); - const { data: documentTypeCounts, isLoading: documentTypesLoading } = - useAtomValue(documentTypeCountsAtom); - const [isOpen, setIsOpen] = useState(false); - const closeTimeoutRef = useRef(null); - - const isLoading = connectorsLoading || documentTypesLoading; - - // Get document types that have documents in the search space - const activeDocumentTypes = documentTypeCounts - ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0) - : []; - - const hasConnectors = connectors.length > 0; - const hasSources = hasConnectors || activeDocumentTypes.length > 0; - const totalSourceCount = connectors.length + activeDocumentTypes.length; - - const handleMouseEnter = useCallback(() => { - // Clear any pending close timeout - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - setIsOpen(true); - }, []); - - const handleMouseLeave = useCallback(() => { - // Delay closing by 150ms for better UX - closeTimeoutRef.current = setTimeout(() => { - setIsOpen(false); - }, 150); - }, []); - - if (!searchSpaceId) return null; - - return ( - - - - - - {hasSources ? ( -
-
-

Connected Sources

- - {totalSourceCount} - -
-
- {/* Document types from the search space */} - {activeDocumentTypes.map(([docType]) => ( -
- {getConnectorIcon(docType, "size-3.5")} - {getDocumentTypeLabel(docType)} -
- ))} - {/* Search source connectors */} - {connectors.map((connector) => ( -
- {getConnectorIcon(connector.connector_type, "size-3.5")} - {connector.name} -
- ))} -
-
- - - Add more sources - - -
-
- ) : ( -
-

No sources yet

-

- Add documents or connect data sources to enhance search results. -

- - - Add Connector - -
- )} -
-
- ); -}; - const ComposerAction: FC = () => { // Check if any attachments are still being processed (running AND progress < 100) // When progress is 100, processing is done but waiting for send() From 577ebdb3e7b0c640c70f3cbdfb71eed7d304b4b0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:51:10 +0530 Subject: [PATCH 03/28] feat: Refactor connector selection UI to a dialog with tabs, search, and connection logic, and add new dialog sub-components. --- .../assistant-ui/connector-popup.tsx | 618 ++++++++++++++---- surfsense_web/components/ui/dialog.tsx | 2 +- 2 files changed, 500 insertions(+), 120 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index fbe137287..12fd7144f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -1,26 +1,170 @@ +"use client"; + import { useAtomValue } from "jotai"; import { - ChevronRightIcon, + ChevronRight, Loader2, Plug2, - Plus, + Search, } from "lucide-react"; import Link from "next/link"; -import { - type FC, - useCallback, - useRef, - useState, -} from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { type FC, useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; +import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; +import { authenticatedFetch } from "@/lib/auth-utils"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; +// OAuth Connectors (Quick Connect) +const OAUTH_CONNECTORS = [ + { + id: "google-gmail-connector", + title: "Gmail", + description: "Search through your emails", + connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, + authEndpoint: "/api/v1/auth/google/gmail/connector/add/", + }, + { + id: "google-calendar-connector", + title: "Google Calendar", + description: "Search through your events", + connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, + authEndpoint: "/api/v1/auth/google/calendar/connector/add/", + }, + { + id: "airtable-connector", + title: "Airtable", + description: "Search your Airtable bases", + connectorType: EnumConnectorName.AIRTABLE_CONNECTOR, + authEndpoint: "/api/v1/auth/airtable/connector/add/", + }, +]; + +// Non-OAuth Connectors +const OTHER_CONNECTORS = [ + { + id: "slack-connector", + title: "Slack", + description: "Search Slack messages", + connectorType: EnumConnectorName.SLACK_CONNECTOR, + }, + { + id: "discord-connector", + title: "Discord", + description: "Search Discord messages", + connectorType: EnumConnectorName.DISCORD_CONNECTOR, + }, + { + id: "notion-connector", + title: "Notion", + description: "Search Notion pages", + connectorType: EnumConnectorName.NOTION_CONNECTOR, + }, + { + id: "confluence-connector", + title: "Confluence", + description: "Search documentation", + connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR, + }, + { + id: "bookstack-connector", + title: "BookStack", + description: "Search BookStack docs", + connectorType: EnumConnectorName.BOOKSTACK_CONNECTOR, + }, + { + id: "github-connector", + title: "GitHub", + description: "Search repositories", + connectorType: EnumConnectorName.GITHUB_CONNECTOR, + }, + { + id: "linear-connector", + title: "Linear", + description: "Search issues & projects", + connectorType: EnumConnectorName.LINEAR_CONNECTOR, + }, + { + id: "jira-connector", + title: "Jira", + description: "Search Jira issues", + connectorType: EnumConnectorName.JIRA_CONNECTOR, + }, + { + id: "clickup-connector", + title: "ClickUp", + description: "Search ClickUp tasks", + connectorType: EnumConnectorName.CLICKUP_CONNECTOR, + }, + { + id: "luma-connector", + title: "Luma", + description: "Search Luma events", + connectorType: EnumConnectorName.LUMA_CONNECTOR, + }, + { + id: "elasticsearch-connector", + title: "Elasticsearch", + description: "Search ES indexes", + connectorType: EnumConnectorName.ELASTICSEARCH_CONNECTOR, + }, + { + id: "webcrawler-connector", + title: "Web Pages", + description: "Crawl web content", + connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR, + }, + { + id: "tavily-api", + title: "Tavily AI", + description: "Search with Tavily", + connectorType: EnumConnectorName.TAVILY_API, + }, + { + id: "searxng", + title: "SearxNG", + description: "Search with SearxNG", + connectorType: EnumConnectorName.SEARXNG_API, + }, + { + id: "linkup-api", + title: "Linkup API", + description: "Search with Linkup", + connectorType: EnumConnectorName.LINKUP_API, + }, + { + id: "baidu-search-api", + title: "Baidu Search", + description: "Search with Baidu", + connectorType: EnumConnectorName.BAIDU_SEARCH_API, + }, +]; + +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; + export const ConnectorIndicator: FC = () => { + const router = useRouter(); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors( false, @@ -28,11 +172,34 @@ export const ConnectorIndicator: FC = () => { ); const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); + const { data: allConnectors } = useAtomValue(connectorsAtom); + const pathname = usePathname(); const [isOpen, setIsOpen] = useState(false); - const closeTimeoutRef = useRef(null); + const [activeTab, setActiveTab] = useState("all"); + const [connectingId, setConnectingId] = useState(null); + const [isScrolled, setIsScrolled] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const isLoading = connectorsLoading || documentTypesLoading; + // Synchronize state with URL path + useEffect(() => { + const pathParts = window.location.pathname.split("/"); + const connectorsIdx = pathParts.indexOf("connectors"); + + if (connectorsIdx !== -1) { + if (!isOpen) setIsOpen(true); + + // Detect tab from URL: .../connectors/active or .../connectors/all + const tabFromUrl = pathParts[connectorsIdx + 1]; + if (tabFromUrl === "active" || tabFromUrl === "all") { + if (activeTab !== tabFromUrl) setActiveTab(tabFromUrl); + } + } else { + if (isOpen) setIsOpen(false); + } + }, [pathname, isOpen, activeTab]); + // Get document types that have documents in the search space const activeDocumentTypes = documentTypeCounts ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0) @@ -42,122 +209,335 @@ export const ConnectorIndicator: FC = () => { const hasSources = hasConnectors || activeDocumentTypes.length > 0; const totalSourceCount = connectors.length + activeDocumentTypes.length; - const handleMouseEnter = useCallback(() => { - // Clear any pending close timeout - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - setIsOpen(true); - }, []); + // Check which connectors are already connected + const connectedTypes = new Set( + (allConnectors || []).map((c: SearchSourceConnector) => c.connector_type) + ); - const handleMouseLeave = useCallback(() => { - // Delay closing by 150ms for better UX - closeTimeoutRef.current = setTimeout(() => { - setIsOpen(false); - }, 150); + // Filter connectors based on search + const filteredOAuth = OAUTH_CONNECTORS.filter(c => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const filteredOther = OTHER_CONNECTORS.filter(c => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Handle OAuth connection + const handleConnectOAuth = useCallback( + async (connector: (typeof OAUTH_CONNECTORS)[0]) => { + if (!searchSpaceId || !connector.authEndpoint) return; + + try { + setConnectingId(connector.id); + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`Failed to initiate ${connector.title} OAuth`); + } + + const data = await response.json(); + window.location.href = data.auth_url; + } catch (error) { + console.error(`Error connecting to ${connector.title}:`, error); + toast.error(`Failed to connect to ${connector.title}`); + } finally { + setConnectingId(null); + } + }, + [searchSpaceId] + ); + + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + + const currentPath = window.location.pathname; + const basePath = currentPath.split("/connectors")[0].replace(/\/$/, ""); + + if (open) { + // Base state is /connectors/all + const newUrl = `${basePath}/connectors/${activeTab}`; + window.history.pushState({ modal: true }, "", newUrl); + } else { + // Return to base chat path + window.history.pushState({ modal: false }, "", basePath || "/"); + setIsScrolled(false); + setSearchQuery(""); + } + }, + [activeTab] + ); + + const handleTabChange = useCallback( + (value: string) => { + setActiveTab(value); + const currentPath = window.location.pathname; + const basePath = currentPath.split("/connectors")[0].replace(/\/$/, ""); + + // Update URL to reflect the new tab state + const newUrl = `${basePath}/connectors/${value}`; + window.history.replaceState({ modal: true }, "", newUrl); + }, + [] + ); + + const handleScroll = useCallback((e: React.UIEvent) => { + setIsScrolled(e.currentTarget.scrollTop > 0); }, []); if (!searchSpaceId) return null; return ( - - - - - + - {hasSources ? ( -
-
-

Connected Sources

- - {totalSourceCount} - -
-
- {/* Document types from the search space */} - {activeDocumentTypes.map(([docType]) => ( -
- {getConnectorIcon(docType, "size-3.5")} - {getDocumentTypeLabel(docType)} -
- ))} - {/* Search source connectors */} - {connectors.map((connector) => ( -
- {getConnectorIcon(connector.connector_type, "size-3.5")} - {connector.name} -
- ))} -
-
- - - Add more sources - - -
-
- ) : ( -
-

No sources yet

-

- Add documents or connect data sources to enhance search results. -

- - - Add Connector - -
+ className={cn( + "size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative", + "hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30", + "outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs", + "border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none" )} -
-
+ aria-label={ + hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector" + } + onClick={() => handleOpenChange(true)} + > + {isLoading ? ( + + ) : ( + <> + + {totalSourceCount > 0 && ( + + {totalSourceCount > 99 ? "99+" : totalSourceCount} + + )} + + )} + + + + + {/* Header */} +
+ + Connectors + + Search across all your apps and data in one place. + + + +
+ + + All Connectors + + + Active + {totalSourceCount > 0 && ( + + {totalSourceCount} + + )} + + +
+ + setSearchQuery(e.target.value)} + /> +
+
+
+ + {/* Content */} +
+
+ + {/* Quick Connect */} + {filteredOAuth.length > 0 && ( +
+
+

Quick Connect

+
+
+ {filteredOAuth.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + const isConnecting = connectingId === connector.id; + + return ( +
+
+ {getConnectorIcon(connector.connectorType, "size-6")} +
+
+
+ {connector.title} + {isConnected && ( + + )} +
+

+ {isConnected ? "Connected" : connector.description} +

+
+ +
+ ); + })} +
+
+ )} + + {/* More Integrations */} + {filteredOther.length > 0 && ( +
+
+

More Integrations

+
+
+ {filteredOther.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + + return ( + +
+ {getConnectorIcon(connector.connectorType, "size-6")} +
+
+
+ {connector.title} + {isConnected && ( + + )} +
+

+ {connector.description} +

+
+ + + ); + })} +
+
+ )} +
+ + + {hasSources ? ( +
+
+

Currently Active

+
+
+ {activeDocumentTypes.map(([docType, count]) => ( +
+
+ {getConnectorIcon(docType, "size-6")} +
+
+

+ {getDocumentTypeLabel(docType)} +

+

+ {count as number} documents indexed +

+
+
+ ))} + {connectors.map((connector) => ( +
+
+ {getConnectorIcon(connector.connector_type, "size-6")} +
+
+

+ {connector.name} +

+

Status: Active

+
+ +
+ ))} +
+
+ ) : ( +
+
+ +
+

No active sources

+

+ Connect your first service to start searching across all your data. +

+ + Browse available connectors + +
+ )} +
+
+
+
+
+ ); }; - diff --git a/surfsense_web/components/ui/dialog.tsx b/surfsense_web/components/ui/dialog.tsx index 47fcfeece..d04d76520 100644 --- a/surfsense_web/components/ui/dialog.tsx +++ b/surfsense_web/components/ui/dialog.tsx @@ -44,7 +44,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close From 733ec5df1348b32436084eafa4fd3b17eb76d47b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:05:38 +0530 Subject: [PATCH 04/28] feat: enhance connector popup responsiveness, update connector icon, and add a scroll fade effect. --- .../assistant-ui/connector-popup.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 12fd7144f..bfcb68847 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -2,9 +2,9 @@ import { useAtomValue } from "jotai"; import { + Cable, ChevronRight, Loader2, - Plug2, Search, } from "lucide-react"; import Link from "next/link"; @@ -313,7 +313,7 @@ export const ConnectorIndicator: FC = () => { ) : ( <> - + {totalSourceCount > 0 && ( {totalSourceCount > 99 ? "99+" : totalSourceCount} @@ -323,24 +323,24 @@ export const ConnectorIndicator: FC = () => { )} - + {/* Header */}
- Connectors - + Connectors + Search across all your apps and data in one place. -
- +
+ { )} -
- - setSearchQuery(e.target.value)} - /> + +
+
+ + setSearchQuery(e.target.value)} + /> +
{/* Content */} -
-
+
+
+
{/* Quick Connect */} {filteredOAuth.length > 0 && ( @@ -382,7 +386,7 @@ export const ConnectorIndicator: FC = () => {

Quick Connect

-
+
{filteredOAuth.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); const isConnecting = connectingId === connector.id; @@ -440,7 +444,7 @@ export const ConnectorIndicator: FC = () => {

More Integrations

-
+
{filteredOther.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); @@ -479,7 +483,7 @@ export const ConnectorIndicator: FC = () => {

Currently Active

-
+
{activeDocumentTypes.map(([docType, count]) => (
{ ) : (
- +

No active sources

@@ -533,8 +537,11 @@ export const ConnectorIndicator: FC = () => {

)} - + +
+ {/* Bottom fade shadow */} +
From 03559ddc01525e0269962eaf0759b71204645639 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:28:15 +0530 Subject: [PATCH 05/28] style: Update connector popup styles for improved UI consistency, including border adjustments, background colors, and hover effects. --- .../assistant-ui/connector-popup.tsx | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index bfcb68847..9043ef657 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -323,7 +323,7 @@ export const ConnectorIndicator: FC = () => { )} - + {/* Header */}
{ -
+
All Connectors - Active + + Active + + {totalSourceCount > 0 && ( {totalSourceCount} @@ -360,13 +363,13 @@ export const ConnectorIndicator: FC = () => { -
+
setSearchQuery(e.target.value)} /> @@ -394,9 +397,9 @@ export const ConnectorIndicator: FC = () => { return (
-
+
{getConnectorIcon(connector.connectorType, "size-6")}
@@ -452,9 +455,9 @@ export const ConnectorIndicator: FC = () => { -
+
{getConnectorIcon(connector.connectorType, "size-6")}
@@ -487,9 +490,9 @@ export const ConnectorIndicator: FC = () => { {activeDocumentTypes.map(([docType, count]) => (
-
+
{getConnectorIcon(docType, "size-6")}
@@ -505,9 +508,9 @@ export const ConnectorIndicator: FC = () => { {connectors.map((connector) => (
-
+
{getConnectorIcon(connector.connector_type, "size-6")}
From 2898192ac4666d971c29f250357592cdfd36ca92 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:49:28 +0530 Subject: [PATCH 06/28] feat: Update redirect URLs in connector routes to include success parameters and improve indexing configuration handling in the connector popup. --- .../routes/airtable_add_connector_route.py | 5 +- .../google_calendar_add_connector_route.py | 4 +- .../google_gmail_add_connector_route.py | 5 +- .../assistant-ui/connector-popup.tsx | 599 ++++++++++++++++-- 4 files changed, 550 insertions(+), 63 deletions(-) diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py index fa124f1c2..3bcbe4dc0 100644 --- a/surfsense_backend/app/routes/airtable_add_connector_route.py +++ b/surfsense_backend/app/routes/airtable_add_connector_route.py @@ -255,9 +255,10 @@ async def airtable_callback( await session.commit() logger.info(f"Successfully saved Airtable connector for user {user_id}") - # Redirect to the frontend success page + # Redirect to the frontend with success params for indexing config + # Using query params to auto-open the popup with config view on new-chat page return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/airtable-connector?success=true" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=airtable-connector" ) except ValidationError as e: diff --git a/surfsense_backend/app/routes/google_calendar_add_connector_route.py b/surfsense_backend/app/routes/google_calendar_add_connector_route.py index fa4ef5466..8bb685450 100644 --- a/surfsense_backend/app/routes/google_calendar_add_connector_route.py +++ b/surfsense_backend/app/routes/google_calendar_add_connector_route.py @@ -131,8 +131,10 @@ async def calendar_callback( session.add(db_connector) await session.commit() await session.refresh(db_connector) + # Redirect to the frontend with success params for indexing config + # Using query params to auto-open the popup with config view on new-chat page return RedirectResponse( - f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/google-calendar-connector?success=true" + f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-calendar-connector" ) except ValidationError as e: await session.rollback() diff --git a/surfsense_backend/app/routes/google_gmail_add_connector_route.py b/surfsense_backend/app/routes/google_gmail_add_connector_route.py index 6d37da244..21fcf2c38 100644 --- a/surfsense_backend/app/routes/google_gmail_add_connector_route.py +++ b/surfsense_backend/app/routes/google_gmail_add_connector_route.py @@ -135,9 +135,10 @@ async def gmail_callback( f"Successfully created Gmail connector for user {user_id} with ID {db_connector.id}" ) - # Redirect to the frontend success page + # Redirect to the frontend with success params for indexing config + # Using query params to auto-open the popup with config view on new-chat page return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/google-gmail-connector?success=true" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-gmail-connector" ) except IntegrityError as e: diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 9043ef657..e55c13448 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -1,17 +1,22 @@ "use client"; +import { format, subDays, subYears } from "date-fns"; import { useAtomValue } from "jotai"; import { + ArrowLeft, Cable, + Calendar as CalendarIcon, + Check, ChevronRight, Loader2, Search, } from "lucide-react"; import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { type FC, useCallback, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; +import { indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; @@ -23,14 +28,35 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { useLogsSummary } from "@/hooks/use-logs"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { queryClient } from "@/lib/query-client/client"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; +// Type for the indexing configuration state +interface IndexingConfigState { + connectorType: string; + connectorId: number; + connectorTitle: string; +} + // OAuth Connectors (Quick Connect) const OAUTH_CONNECTORS = [ { @@ -165,44 +191,243 @@ import { export const ConnectorIndicator: FC = () => { const router = useRouter(); + const searchParams = useSearchParams(); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors( + const { connectors, isLoading: connectorsLoading, refreshConnectors } = useSearchSourceConnectors( false, searchSpaceId ? Number(searchSpaceId) : undefined ); const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); - const { data: allConnectors } = useAtomValue(connectorsAtom); - const pathname = usePathname(); + const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); + const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); + const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); const [isOpen, setIsOpen] = useState(false); const [activeTab, setActiveTab] = useState("all"); const [connectingId, setConnectingId] = useState(null); const [isScrolled, setIsScrolled] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + + // Indexing configuration state (shown after OAuth success) + const [indexingConfig, setIndexingConfig] = useState(null); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [isStartingIndexing, setIsStartingIndexing] = useState(false); + + // Periodic indexing state + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); // Default: daily + + // Helper function to get frequency label + const getFrequencyLabel = useCallback((minutes: string): string => { + switch (minutes) { + case "15": return "15 minutes"; + case "60": return "hour"; + case "360": return "6 hours"; + case "720": return "12 hours"; + case "1440": return "day"; + case "10080": return "week"; + default: return `${minutes} minutes`; + } + }, []); + + // Track active indexing tasks + const { summary: logsSummary } = useLogsSummary( + searchSpaceId ? Number(searchSpaceId) : 0, + 24, + { + enablePolling: true, + refetchInterval: 5000, + } + ); + + // Get connector IDs that are currently being indexed + const indexingConnectorIds = useMemo(() => { + if (!logsSummary?.active_tasks) return new Set(); + return new Set( + logsSummary.active_tasks + .filter((task) => task.source?.includes("connector_indexing")) + .map((task) => { + // Extract connector ID from task metadata or source + const match = task.source?.match(/connector[_-]?(\d+)/i); + return match ? parseInt(match[1], 10) : null; + }) + .filter((id): id is number => id !== null) + ); + }, [logsSummary?.active_tasks]); const isLoading = connectorsLoading || documentTypesLoading; - // Synchronize state with URL path + // Synchronize state with URL query params useEffect(() => { - const pathParts = window.location.pathname.split("/"); - const connectorsIdx = pathParts.indexOf("connectors"); + const modalParam = searchParams.get("modal"); + const tabParam = searchParams.get("tab"); + const viewParam = searchParams.get("view"); + const connectorParam = searchParams.get("connector"); - if (connectorsIdx !== -1) { + if (modalParam === "connectors") { if (!isOpen) setIsOpen(true); - // Detect tab from URL: .../connectors/active or .../connectors/all - const tabFromUrl = pathParts[connectorsIdx + 1]; - if (tabFromUrl === "active" || tabFromUrl === "all") { - if (activeTab !== tabFromUrl) setActiveTab(tabFromUrl); + // Detect tab from URL query param + if (tabParam === "active" || tabParam === "all") { + if (activeTab !== tabParam) setActiveTab(tabParam); + } + + // Restore indexing config view from URL if present (e.g., on page refresh) + if (viewParam === "configure" && connectorParam && !indexingConfig) { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); + if (oauthConnector && allConnectors) { + const existingConnector = allConnectors.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (existingConnector) { + setIndexingConfig({ + connectorType: oauthConnector.connectorType, + connectorId: existingConnector.id, + connectorTitle: oauthConnector.title, + }); + } + } } } else { if (isOpen) setIsOpen(false); } - }, [pathname, isOpen, activeTab]); + }, [searchParams, isOpen, activeTab, indexingConfig, allConnectors]); + + // Detect OAuth success and transition to config view + useEffect(() => { + const success = searchParams.get("success"); + const connectorParam = searchParams.get("connector"); + const modalParam = searchParams.get("modal"); + + if (success === "true" && connectorParam && searchSpaceId && modalParam === "connectors") { + // Find the OAuth connector info + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); + if (oauthConnector) { + // Refetch connectors to get the newly created connector + refetchAllConnectors().then((result) => { + const newConnector = result.data?.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (newConnector) { + setIndexingConfig({ + connectorType: oauthConnector.connectorType, + connectorId: newConnector.id, + connectorTitle: oauthConnector.title, + }); + setIsOpen(true); + // Update URL to reflect config view (replace success with view=configure) + const url = new URL(window.location.href); + url.searchParams.delete("success"); + url.searchParams.set("view", "configure"); + // Keep connector param for URL restoration + window.history.replaceState({}, "", url.toString()); + } + }); + } + } + }, [searchParams, searchSpaceId, refetchAllConnectors]); + + // Handle starting indexing + const handleStartIndexing = useCallback(async () => { + if (!indexingConfig || !searchSpaceId) return; + + setIsStartingIndexing(true); + try { + const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; + const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; + + // Update periodic indexing settings if enabled + if (periodicEnabled) { + const frequency = parseInt(frequencyMinutes, 10); + await updateConnector({ + id: indexingConfig.connectorId, + data: { + periodic_indexing_enabled: true, + indexing_frequency_minutes: frequency, + }, + }); + } + + await indexConnector({ + connector_id: indexingConfig.connectorId, + queryParams: { + search_space_id: searchSpaceId, + start_date: startDateStr, + end_date: endDateStr, + }, + }); + + toast.success(`${indexingConfig.connectorTitle} indexing started`, { + description: periodicEnabled + ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` + : "You can continue working while we sync your data.", + }); + + // Close the config view and reset state + setIndexingConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + + // Clear config-related URL params and switch to active tab + const url = new URL(window.location.href); + url.searchParams.delete("view"); + url.searchParams.delete("connector"); + url.searchParams.set("tab", "active"); + window.history.replaceState({}, "", url.toString()); + setActiveTab("active"); + + // Refresh connectors list + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error starting indexing:", error); + toast.error("Failed to start indexing"); + } finally { + setIsStartingIndexing(false); + } + }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, refreshConnectors, getFrequencyLabel]); + + // Handle skipping indexing for now + const handleSkipIndexing = useCallback(() => { + setIndexingConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + + // Clear config-related URL params + const url = new URL(window.location.href); + url.searchParams.delete("view"); + url.searchParams.delete("connector"); + window.history.replaceState({}, "", url.toString()); + }, []); + + // Quick date range handlers + const handleLast30Days = useCallback(() => { + const today = new Date(); + setStartDate(subDays(today, 30)); + setEndDate(today); + }, []); + + const handleLastYear = useCallback(() => { + const today = new Date(); + setStartDate(subYears(today, 1)); + setEndDate(today); + }, []); + + const handleClearDates = useCallback(() => { + setStartDate(undefined); + setEndDate(undefined); + }, []); // Get document types that have documents in the search space const activeDocumentTypes = documentTypeCounts - ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0) + ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) : []; const hasConnectors = connectors.length > 0; @@ -257,32 +482,43 @@ export const ConnectorIndicator: FC = () => { (open: boolean) => { setIsOpen(open); - const currentPath = window.location.pathname; - const basePath = currentPath.split("/connectors")[0].replace(/\/$/, ""); - if (open) { - // Base state is /connectors/all - const newUrl = `${basePath}/connectors/${activeTab}`; - window.history.pushState({ modal: true }, "", newUrl); + // Add modal query params to current URL + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", activeTab); + window.history.pushState({ modal: true }, "", url.toString()); } else { - // Return to base chat path - window.history.pushState({ modal: false }, "", basePath || "/"); + // Remove modal query params from URL + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + window.history.pushState({ modal: false }, "", url.toString()); setIsScrolled(false); setSearchQuery(""); + // Reset indexing config when closing + if (!isStartingIndexing) { + setIndexingConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + } } }, - [activeTab] + [activeTab, isStartingIndexing] ); const handleTabChange = useCallback( (value: string) => { setActiveTab(value); - const currentPath = window.location.pathname; - const basePath = currentPath.split("/connectors")[0].replace(/\/$/, ""); - - // Update URL to reflect the new tab state - const newUrl = `${basePath}/connectors/${value}`; - window.history.replaceState({ modal: true }, "", newUrl); + // Update tab query param + const url = new URL(window.location.href); + url.searchParams.set("tab", value); + window.history.replaceState({ modal: true }, "", url.toString()); }, [] ); @@ -323,8 +559,215 @@ export const ConnectorIndicator: FC = () => { )} - - + + {/* Indexing Configuration View - shown after OAuth success */} + {indexingConfig ? ( +
+ {/* Fixed Header */} +
+ {/* Back button */} + + + {/* Success header */} +
+
+ +
+
+

+ {indexingConfig.connectorTitle} Connected! +

+

+ Configure when to start syncing your data +

+
+
+
+ + {/* Scrollable Content */} +
+
+
+

Select Date Range

+

+ Choose how far back you want to sync your data. You can always re-index later with different dates. +

+ +
+ {/* Start Date */} +
+ + + + + + + date > new Date()} + /> + + +
+ + {/* End Date */} +
+ + + + + + + date > new Date() || (startDate ? date < startDate : false)} + /> + + +
+
+ + {/* Quick date range buttons */} +
+ + + +
+
+ + {/* Periodic Indexing Configuration */} +
+
+
+

Enable Periodic Sync

+

+ Automatically re-index at regular intervals +

+
+ +
+ + {periodicEnabled && ( +
+
+ + +
+
+ )} +
+ + {/* Info box */} +
+
+ {getConnectorIcon(indexingConfig.connectorType, "size-4")} +
+
+

Indexing runs in the background

+

+ You can continue using SurfSense while we sync your data. Check the Active tab to see progress. +

+
+
+
+
+ + {/* Fixed Footer - Action buttons */} +
+ + +
+
+ ) : ( + {/* Header */}
{ -
+
{ setSearchQuery(e.target.value)} /> @@ -397,9 +840,9 @@ export const ConnectorIndicator: FC = () => { return (
-
+
{getConnectorIcon(connector.connectorType, "size-6")}
@@ -455,9 +898,9 @@ export const ConnectorIndicator: FC = () => { -
+
{getConnectorIcon(connector.connectorType, "size-6")}
@@ -490,9 +933,9 @@ export const ConnectorIndicator: FC = () => { {activeDocumentTypes.map(([docType, count]) => (
-
+
{getConnectorIcon(docType, "size-6")}
@@ -505,25 +948,64 @@ export const ConnectorIndicator: FC = () => {
))} - {connectors.map((connector) => ( -
-
- {getConnectorIcon(connector.connector_type, "size-6")} + {connectors.map((connector) => { + const isIndexing = indexingConnectorIds.has(connector.id); + const activeTask = logsSummary?.active_tasks?.find( + (task) => task.source?.includes(`connector_${connector.id}`) || task.source?.includes(`connector-${connector.id}`) + ); + + return ( +
+
+ {getConnectorIcon(connector.connector_type, "size-6")} +
+
+

+ {connector.name} +

+ {isIndexing ? ( +

+ + Indexing... + {activeTask?.message && ( + + • {activeTask.message} + + )} +

+ ) : ( +

+ {connector.last_indexed_at + ? `Last indexed: ${format(new Date(connector.last_indexed_at), "MMM d, yyyy")}` + : "Never indexed"} +

+ )} +
+
-
-

- {connector.name} -

-

Status: Active

-
- -
- ))} + ); + })}
) : ( @@ -547,6 +1029,7 @@ export const ConnectorIndicator: FC = () => {
+ )} ); From 32b4a09c0e803e1a2f66bbac8150da7108bb811b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:07:15 +0530 Subject: [PATCH 07/28] feat: Refactor connector popup to include new components for active and all connectors, enhance indexing configuration view, and improve date range selection functionality. --- .../assistant-ui/connector-popup.tsx | 1022 ++--------------- .../connector-popup/active-connectors-tab.tsx | 152 +++ .../connector-popup/all-connectors-tab.tsx | 125 ++ .../connector-popup/connector-card.tsx | 66 ++ .../connector-popup/connector-constants.ts | 134 +++ .../connector-dialog-header.tsx | 89 ++ .../connector-popup/date-range-selector.tsx | 140 +++ .../assistant-ui/connector-popup/index.ts | 19 + .../indexing-configuration-view.tsx | 121 ++ .../connector-popup/periodic-sync-config.tsx | 65 ++ .../connector-popup/use-connector-dialog.ts | 295 +++++ 11 files changed, 1298 insertions(+), 930 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/index.ts create mode 100644 surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index e55c13448..1e9e09869 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -1,197 +1,30 @@ "use client"; -import { format, subDays, subYears } from "date-fns"; import { useAtomValue } from "jotai"; -import { - ArrowLeft, - Cable, - Calendar as CalendarIcon, - Check, - ChevronRight, - Loader2, - Search, -} from "lucide-react"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { type FC, useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; -import { indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms"; -import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; +import { Cable, Loader2 } from "lucide-react"; +import { type FC, useMemo } from "react"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { useLogsSummary } from "@/hooks/use-logs"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Dialog, DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; -import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { EnumConnectorName } from "@/contracts/enums/connector"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { useLogsSummary } from "@/hooks/use-logs"; -import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; -import { authenticatedFetch } from "@/lib/auth-utils"; -import { queryClient } from "@/lib/query-client/client"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { cn } from "@/lib/utils"; - -// Type for the indexing configuration state -interface IndexingConfigState { - connectorType: string; - connectorId: number; - connectorTitle: string; -} - -// OAuth Connectors (Quick Connect) -const OAUTH_CONNECTORS = [ - { - id: "google-gmail-connector", - title: "Gmail", - description: "Search through your emails", - connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, - authEndpoint: "/api/v1/auth/google/gmail/connector/add/", - }, - { - id: "google-calendar-connector", - title: "Google Calendar", - description: "Search through your events", - connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, - authEndpoint: "/api/v1/auth/google/calendar/connector/add/", - }, - { - id: "airtable-connector", - title: "Airtable", - description: "Search your Airtable bases", - connectorType: EnumConnectorName.AIRTABLE_CONNECTOR, - authEndpoint: "/api/v1/auth/airtable/connector/add/", - }, -]; - -// Non-OAuth Connectors -const OTHER_CONNECTORS = [ - { - id: "slack-connector", - title: "Slack", - description: "Search Slack messages", - connectorType: EnumConnectorName.SLACK_CONNECTOR, - }, - { - id: "discord-connector", - title: "Discord", - description: "Search Discord messages", - connectorType: EnumConnectorName.DISCORD_CONNECTOR, - }, - { - id: "notion-connector", - title: "Notion", - description: "Search Notion pages", - connectorType: EnumConnectorName.NOTION_CONNECTOR, - }, - { - id: "confluence-connector", - title: "Confluence", - description: "Search documentation", - connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR, - }, - { - id: "bookstack-connector", - title: "BookStack", - description: "Search BookStack docs", - connectorType: EnumConnectorName.BOOKSTACK_CONNECTOR, - }, - { - id: "github-connector", - title: "GitHub", - description: "Search repositories", - connectorType: EnumConnectorName.GITHUB_CONNECTOR, - }, - { - id: "linear-connector", - title: "Linear", - description: "Search issues & projects", - connectorType: EnumConnectorName.LINEAR_CONNECTOR, - }, - { - id: "jira-connector", - title: "Jira", - description: "Search Jira issues", - connectorType: EnumConnectorName.JIRA_CONNECTOR, - }, - { - id: "clickup-connector", - title: "ClickUp", - description: "Search ClickUp tasks", - connectorType: EnumConnectorName.CLICKUP_CONNECTOR, - }, - { - id: "luma-connector", - title: "Luma", - description: "Search Luma events", - connectorType: EnumConnectorName.LUMA_CONNECTOR, - }, - { - id: "elasticsearch-connector", - title: "Elasticsearch", - description: "Search ES indexes", - connectorType: EnumConnectorName.ELASTICSEARCH_CONNECTOR, - }, - { - id: "webcrawler-connector", - title: "Web Pages", - description: "Crawl web content", - connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR, - }, - { - id: "tavily-api", - title: "Tavily AI", - description: "Search with Tavily", - connectorType: EnumConnectorName.TAVILY_API, - }, - { - id: "searxng", - title: "SearxNG", - description: "Search with SearxNG", - connectorType: EnumConnectorName.SEARXNG_API, - }, - { - id: "linkup-api", - title: "Linkup API", - description: "Search with Linkup", - connectorType: EnumConnectorName.LINKUP_API, - }, - { - id: "baidu-search-api", - title: "Baidu Search", - description: "Search with Baidu", - connectorType: EnumConnectorName.BAIDU_SEARCH_API, - }, -]; - import { Tabs, TabsContent, - TabsList, - TabsTrigger, } from "@/components/ui/tabs"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; +import { AllConnectorsTab } from "./connector-popup/all-connectors-tab"; +import { ActiveConnectorsTab } from "./connector-popup/active-connectors-tab"; +import { ConnectorDialogHeader } from "./connector-popup/connector-dialog-header"; +import { IndexingConfigurationView } from "./connector-popup/indexing-configuration-view"; +import { useConnectorDialog } from "./connector-popup/use-connector-dialog"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; export const ConnectorIndicator: FC = () => { - const router = useRouter(); - const searchParams = useSearchParams(); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const { connectors, isLoading: connectorsLoading, refreshConnectors } = useSearchSourceConnectors( false, @@ -199,37 +32,6 @@ export const ConnectorIndicator: FC = () => { ); const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); - const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); - const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); - const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); - const [isOpen, setIsOpen] = useState(false); - const [activeTab, setActiveTab] = useState("all"); - const [connectingId, setConnectingId] = useState(null); - const [isScrolled, setIsScrolled] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - - // Indexing configuration state (shown after OAuth success) - const [indexingConfig, setIndexingConfig] = useState(null); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [isStartingIndexing, setIsStartingIndexing] = useState(false); - - // Periodic indexing state - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); // Default: daily - - // Helper function to get frequency label - const getFrequencyLabel = useCallback((minutes: string): string => { - switch (minutes) { - case "15": return "15 minutes"; - case "60": return "hour"; - case "360": return "6 hours"; - case "720": return "12 hours"; - case "1440": return "day"; - case "10080": return "week"; - default: return `${minutes} minutes`; - } - }, []); // Track active indexing tasks const { summary: logsSummary } = useLogsSummary( @@ -248,7 +50,6 @@ export const ConnectorIndicator: FC = () => { logsSummary.active_tasks .filter((task) => task.source?.includes("connector_indexing")) .map((task) => { - // Extract connector ID from task metadata or source const match = task.source?.match(/connector[_-]?(\d+)/i); return match ? parseInt(match[1], 10) : null; }) @@ -258,172 +59,32 @@ export const ConnectorIndicator: FC = () => { const isLoading = connectorsLoading || documentTypesLoading; - // Synchronize state with URL query params - useEffect(() => { - const modalParam = searchParams.get("modal"); - const tabParam = searchParams.get("tab"); - const viewParam = searchParams.get("view"); - const connectorParam = searchParams.get("connector"); - - if (modalParam === "connectors") { - if (!isOpen) setIsOpen(true); - - // Detect tab from URL query param - if (tabParam === "active" || tabParam === "all") { - if (activeTab !== tabParam) setActiveTab(tabParam); - } - - // Restore indexing config view from URL if present (e.g., on page refresh) - if (viewParam === "configure" && connectorParam && !indexingConfig) { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); - if (oauthConnector && allConnectors) { - const existingConnector = allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (existingConnector) { - setIndexingConfig({ - connectorType: oauthConnector.connectorType, - connectorId: existingConnector.id, - connectorTitle: oauthConnector.title, - }); - } - } - } - } else { - if (isOpen) setIsOpen(false); - } - }, [searchParams, isOpen, activeTab, indexingConfig, allConnectors]); - - // Detect OAuth success and transition to config view - useEffect(() => { - const success = searchParams.get("success"); - const connectorParam = searchParams.get("connector"); - const modalParam = searchParams.get("modal"); - - if (success === "true" && connectorParam && searchSpaceId && modalParam === "connectors") { - // Find the OAuth connector info - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); - if (oauthConnector) { - // Refetch connectors to get the newly created connector - refetchAllConnectors().then((result) => { - const newConnector = result.data?.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (newConnector) { - setIndexingConfig({ - connectorType: oauthConnector.connectorType, - connectorId: newConnector.id, - connectorTitle: oauthConnector.title, - }); - setIsOpen(true); - // Update URL to reflect config view (replace success with view=configure) - const url = new URL(window.location.href); - url.searchParams.delete("success"); - url.searchParams.set("view", "configure"); - // Keep connector param for URL restoration - window.history.replaceState({}, "", url.toString()); - } - }); - } - } - }, [searchParams, searchSpaceId, refetchAllConnectors]); - - // Handle starting indexing - const handleStartIndexing = useCallback(async () => { - if (!indexingConfig || !searchSpaceId) return; - - setIsStartingIndexing(true); - try { - const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; - const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; - - // Update periodic indexing settings if enabled - if (periodicEnabled) { - const frequency = parseInt(frequencyMinutes, 10); - await updateConnector({ - id: indexingConfig.connectorId, - data: { - periodic_indexing_enabled: true, - indexing_frequency_minutes: frequency, - }, - }); - } - - await indexConnector({ - connector_id: indexingConfig.connectorId, - queryParams: { - search_space_id: searchSpaceId, - start_date: startDateStr, - end_date: endDateStr, - }, - }); - - toast.success(`${indexingConfig.connectorTitle} indexing started`, { - description: periodicEnabled - ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` - : "You can continue working while we sync your data.", - }); - - // Close the config view and reset state - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - - // Clear config-related URL params and switch to active tab - const url = new URL(window.location.href); - url.searchParams.delete("view"); - url.searchParams.delete("connector"); - url.searchParams.set("tab", "active"); - window.history.replaceState({}, "", url.toString()); - setActiveTab("active"); - - // Refresh connectors list - refreshConnectors(); - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - } catch (error) { - console.error("Error starting indexing:", error); - toast.error("Failed to start indexing"); - } finally { - setIsStartingIndexing(false); - } - }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, refreshConnectors, getFrequencyLabel]); - - // Handle skipping indexing for now - const handleSkipIndexing = useCallback(() => { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - - // Clear config-related URL params - const url = new URL(window.location.href); - url.searchParams.delete("view"); - url.searchParams.delete("connector"); - window.history.replaceState({}, "", url.toString()); - }, []); - - // Quick date range handlers - const handleLast30Days = useCallback(() => { - const today = new Date(); - setStartDate(subDays(today, 30)); - setEndDate(today); - }, []); - - const handleLastYear = useCallback(() => { - const today = new Date(); - setStartDate(subYears(today, 1)); - setEndDate(today); - }, []); - - const handleClearDates = useCallback(() => { - setStartDate(undefined); - setEndDate(undefined); - }, []); + // Use the custom hook for dialog state management + const { + isOpen, + activeTab, + connectingId, + isScrolled, + searchQuery, + indexingConfig, + startDate, + endDate, + isStartingIndexing, + periodicEnabled, + frequencyMinutes, + allConnectors, + setSearchQuery, + setStartDate, + setEndDate, + setPeriodicEnabled, + setFrequencyMinutes, + handleOpenChange, + handleTabChange, + handleScroll, + handleConnectOAuth, + handleStartIndexing, + handleSkipIndexing, + } = useConnectorDialog(); // Get document types that have documents in the search space const activeDocumentTypes = documentTypeCounts @@ -439,94 +100,6 @@ export const ConnectorIndicator: FC = () => { (allConnectors || []).map((c: SearchSourceConnector) => c.connector_type) ); - // Filter connectors based on search - const filteredOAuth = OAUTH_CONNECTORS.filter(c => - c.title.toLowerCase().includes(searchQuery.toLowerCase()) || - c.description.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const filteredOther = OTHER_CONNECTORS.filter(c => - c.title.toLowerCase().includes(searchQuery.toLowerCase()) || - c.description.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - // Handle OAuth connection - const handleConnectOAuth = useCallback( - async (connector: (typeof OAUTH_CONNECTORS)[0]) => { - if (!searchSpaceId || !connector.authEndpoint) return; - - try { - setConnectingId(connector.id); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, - { method: "GET" } - ); - - if (!response.ok) { - throw new Error(`Failed to initiate ${connector.title} OAuth`); - } - - const data = await response.json(); - window.location.href = data.auth_url; - } catch (error) { - console.error(`Error connecting to ${connector.title}:`, error); - toast.error(`Failed to connect to ${connector.title}`); - } finally { - setConnectingId(null); - } - }, - [searchSpaceId] - ); - - const handleOpenChange = useCallback( - (open: boolean) => { - setIsOpen(open); - - if (open) { - // Add modal query params to current URL - const url = new URL(window.location.href); - url.searchParams.set("modal", "connectors"); - url.searchParams.set("tab", activeTab); - window.history.pushState({ modal: true }, "", url.toString()); - } else { - // Remove modal query params from URL - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - window.history.pushState({ modal: false }, "", url.toString()); - setIsScrolled(false); - setSearchQuery(""); - // Reset indexing config when closing - if (!isStartingIndexing) { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - } - } - }, - [activeTab, isStartingIndexing] - ); - - const handleTabChange = useCallback( - (value: string) => { - setActiveTab(value); - // Update tab query param - const url = new URL(window.location.href); - url.searchParams.set("tab", value); - window.history.replaceState({ modal: true }, "", url.toString()); - }, - [] - ); - - const handleScroll = useCallback((e: React.UIEvent) => { - setIsScrolled(e.currentTarget.scrollTop > 0); - }, []); - if (!searchSpaceId) return null; return ( @@ -559,477 +132,66 @@ export const ConnectorIndicator: FC = () => { )} - - {/* Indexing Configuration View - shown after OAuth success */} - {indexingConfig ? ( -
- {/* Fixed Header */} -
- {/* Back button */} - + + {/* Indexing Configuration View - shown after OAuth success */} + {indexingConfig ? ( + handleStartIndexing(refreshConnectors)} + onSkip={handleSkipIndexing} + /> + ) : ( + + {/* Header */} + - {/* Success header */} -
-
- -
-
-

- {indexingConfig.connectorTitle} Connected! -

-

- Configure when to start syncing your data -

-
-
-
+ {/* Content */} +
+
+
+ + + - {/* Scrollable Content */} -
-
-
-

Select Date Range

-

- Choose how far back you want to sync your data. You can always re-index later with different dates. -

- -
- {/* Start Date */} -
- - - - - - - date > new Date()} - /> - - -
- - {/* End Date */} -
- - - - - - - date > new Date() || (startDate ? date < startDate : false)} - /> - - -
-
- - {/* Quick date range buttons */} -
- - - -
-
- - {/* Periodic Indexing Configuration */} -
-
-
-

Enable Periodic Sync

-

- Automatically re-index at regular intervals -

-
- -
- - {periodicEnabled && ( -
-
- - -
-
- )} -
- - {/* Info box */} -
-
- {getConnectorIcon(indexingConfig.connectorType, "size-4")} -
-
-

Indexing runs in the background

-

- You can continue using SurfSense while we sync your data. Check the Active tab to see progress. -

-
-
-
-
- - {/* Fixed Footer - Action buttons */} -
- - -
-
- ) : ( - - {/* Header */} -
- - Connectors - - Search across all your apps and data in one place. - - - -
- - - All Connectors - - - - Active - - - {totalSourceCount > 0 && ( - - {totalSourceCount} - - )} - - - -
-
- - setSearchQuery(e.target.value)} +
+ {/* Bottom fade shadow */} +
-
- - {/* Content */} -
-
-
- - {/* Quick Connect */} - {filteredOAuth.length > 0 && ( -
-
-

Quick Connect

-
-
- {filteredOAuth.map((connector) => { - const isConnected = connectedTypes.has(connector.connectorType); - const isConnecting = connectingId === connector.id; - - return ( -
-
- {getConnectorIcon(connector.connectorType, "size-6")} -
-
-
- {connector.title} - {isConnected && ( - - )} -
-

- {isConnected ? "Connected" : connector.description} -

-
- -
- ); - })} -
-
- )} - - {/* More Integrations */} - {filteredOther.length > 0 && ( -
-
-

More Integrations

-
-
- {filteredOther.map((connector) => { - const isConnected = connectedTypes.has(connector.connectorType); - - return ( - -
- {getConnectorIcon(connector.connectorType, "size-6")} -
-
-
- {connector.title} - {isConnected && ( - - )} -
-

- {connector.description} -

-
- - - ); - })} -
-
- )} -
- - - {hasSources ? ( -
-
-

Currently Active

-
-
- {activeDocumentTypes.map(([docType, count]) => ( -
-
- {getConnectorIcon(docType, "size-6")} -
-
-

- {getDocumentTypeLabel(docType)} -

-

- {count as number} documents indexed -

-
-
- ))} - {connectors.map((connector) => { - const isIndexing = indexingConnectorIds.has(connector.id); - const activeTask = logsSummary?.active_tasks?.find( - (task) => task.source?.includes(`connector_${connector.id}`) || task.source?.includes(`connector-${connector.id}`) - ); - - return ( -
-
- {getConnectorIcon(connector.connector_type, "size-6")} -
-
-

- {connector.name} -

- {isIndexing ? ( -

- - Indexing... - {activeTask?.message && ( - - • {activeTask.message} - - )} -

- ) : ( -

- {connector.last_indexed_at - ? `Last indexed: ${format(new Date(connector.last_indexed_at), "MMM d, yyyy")}` - : "Never indexed"} -

- )} -
- -
- ); - })} -
-
- ) : ( -
-
- -
-

No active sources

-

- Connect your first service to start searching across all your data. -

- - Browse available connectors - -
- )} -
-
-
- {/* Bottom fade shadow */} -
-
- - )} + + )} ); diff --git a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx new file mode 100644 index 000000000..323fa34e7 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { format } from "date-fns"; +import { Cable, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import type { FC } from "react"; +import { Button } from "@/components/ui/button"; +import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { cn } from "@/lib/utils"; +import { + TabsContent, + TabsTrigger, +} from "@/components/ui/tabs"; + +interface ActiveConnectorsTabProps { + hasSources: boolean; + totalSourceCount: number; + activeDocumentTypes: Array<[string, number]>; + connectors: SearchSourceConnector[]; + indexingConnectorIds: Set; + logsSummary: any; + searchSpaceId: string; + onTabChange: (value: string) => void; +} + +export const ActiveConnectorsTab: FC = ({ + hasSources, + activeDocumentTypes, + connectors, + indexingConnectorIds, + logsSummary, + searchSpaceId, + onTabChange, +}) => { + const router = useRouter(); + + return ( + + {hasSources ? ( +
+
+

+ Currently Active +

+
+
+ {activeDocumentTypes.map(([docType, count]) => ( +
+
+ {getConnectorIcon(docType, "size-6")} +
+
+

+ {getDocumentTypeLabel(docType)} +

+

+ {count as number} documents indexed +

+
+
+ ))} + {connectors.map((connector) => { + const isIndexing = indexingConnectorIds.has(connector.id); + const activeTask = logsSummary?.active_tasks?.find( + (task: any) => + task.source?.includes(`connector_${connector.id}`) || + task.source?.includes(`connector-${connector.id}`) + ); + + return ( +
+
+ {getConnectorIcon(connector.connector_type, "size-6")} +
+
+

+ {connector.name} +

+ {isIndexing ? ( +

+ + Indexing... + {activeTask?.message && ( + + • {activeTask.message} + + )} +

+ ) : ( +

+ {connector.last_indexed_at + ? `Last indexed: ${format(new Date(connector.last_indexed_at), "MMM d, yyyy")}` + : "Never indexed"} +

+ )} +
+ +
+ ); + })} +
+
+ ) : ( +
+
+ +
+

No active sources

+

+ Connect your first service to start searching across all your data. +

+ onTabChange("all")}> + Browse available connectors + +
+ )} +
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx new file mode 100644 index 000000000..e2f735b13 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type FC } from "react"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; +import { ConnectorCard } from "./connector-card"; + +interface AllConnectorsTabProps { + searchQuery: string; + searchSpaceId: string; + connectedTypes: Set; + connectingId: string | null; + onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[0]) => void; +} + +export const AllConnectorsTab: FC = ({ + searchQuery, + searchSpaceId, + connectedTypes, + connectingId, + onConnectOAuth, +}) => { + const router = useRouter(); + + // Filter connectors based on search + const filteredOAuth = OAUTH_CONNECTORS.filter( + (c) => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const filteredOther = OTHER_CONNECTORS.filter( + (c) => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+ {/* Quick Connect */} + {filteredOAuth.length > 0 && ( +
+
+

+ Quick Connect +

+
+
+ {filteredOAuth.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + const isConnecting = connectingId === connector.id; + + return ( + onConnectOAuth(connector)} + onManage={() => + router.push( + `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` + ) + } + /> + ); + })} +
+
+ )} + + {/* More Integrations */} + {filteredOther.length > 0 && ( +
+
+

+ More Integrations +

+
+
+ {filteredOther.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + + return ( + +
+ {getConnectorIcon(connector.connectorType, "size-6")} +
+
+
+ + {connector.title} + + {isConnected && ( + + )} +
+

+ {connector.description} +

+
+ + + ); + })} +
+
+ )} +
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx new file mode 100644 index 000000000..d1f79b16d --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { type FC } from "react"; +import { Button } from "@/components/ui/button"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; + +interface ConnectorCardProps { + id: string; + title: string; + description: string; + connectorType: string; + isConnected?: boolean; + isConnecting?: boolean; + onConnect?: () => void; + onManage?: () => void; +} + +export const ConnectorCard: FC = ({ + id, + title, + description, + connectorType, + isConnected = false, + isConnecting = false, + onConnect, + onManage, +}) => { + return ( +
+
+ {getConnectorIcon(connectorType, "size-6")} +
+
+
+ {title} + {isConnected && ( + + )} +
+

+ {isConnected ? "Connected" : description} +

+
+ +
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts new file mode 100644 index 000000000..65d5bd516 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts @@ -0,0 +1,134 @@ +import { EnumConnectorName } from "@/contracts/enums/connector"; + +// OAuth Connectors (Quick Connect) +export const OAUTH_CONNECTORS = [ + { + id: "google-gmail-connector", + title: "Gmail", + description: "Search through your emails", + connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, + authEndpoint: "/api/v1/auth/google/gmail/connector/add/", + }, + { + id: "google-calendar-connector", + title: "Google Calendar", + description: "Search through your events", + connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, + authEndpoint: "/api/v1/auth/google/calendar/connector/add/", + }, + { + id: "airtable-connector", + title: "Airtable", + description: "Search your Airtable bases", + connectorType: EnumConnectorName.AIRTABLE_CONNECTOR, + authEndpoint: "/api/v1/auth/airtable/connector/add/", + }, +] as const; + +// Non-OAuth Connectors +export const OTHER_CONNECTORS = [ + { + id: "slack-connector", + title: "Slack", + description: "Search Slack messages", + connectorType: EnumConnectorName.SLACK_CONNECTOR, + }, + { + id: "discord-connector", + title: "Discord", + description: "Search Discord messages", + connectorType: EnumConnectorName.DISCORD_CONNECTOR, + }, + { + id: "notion-connector", + title: "Notion", + description: "Search Notion pages", + connectorType: EnumConnectorName.NOTION_CONNECTOR, + }, + { + id: "confluence-connector", + title: "Confluence", + description: "Search documentation", + connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR, + }, + { + id: "bookstack-connector", + title: "BookStack", + description: "Search BookStack docs", + connectorType: EnumConnectorName.BOOKSTACK_CONNECTOR, + }, + { + id: "github-connector", + title: "GitHub", + description: "Search repositories", + connectorType: EnumConnectorName.GITHUB_CONNECTOR, + }, + { + id: "linear-connector", + title: "Linear", + description: "Search issues & projects", + connectorType: EnumConnectorName.LINEAR_CONNECTOR, + }, + { + id: "jira-connector", + title: "Jira", + description: "Search Jira issues", + connectorType: EnumConnectorName.JIRA_CONNECTOR, + }, + { + id: "clickup-connector", + title: "ClickUp", + description: "Search ClickUp tasks", + connectorType: EnumConnectorName.CLICKUP_CONNECTOR, + }, + { + id: "luma-connector", + title: "Luma", + description: "Search Luma events", + connectorType: EnumConnectorName.LUMA_CONNECTOR, + }, + { + id: "elasticsearch-connector", + title: "Elasticsearch", + description: "Search ES indexes", + connectorType: EnumConnectorName.ELASTICSEARCH_CONNECTOR, + }, + { + id: "webcrawler-connector", + title: "Web Pages", + description: "Crawl web content", + connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR, + }, + { + id: "tavily-api", + title: "Tavily AI", + description: "Search with Tavily", + connectorType: EnumConnectorName.TAVILY_API, + }, + { + id: "searxng", + title: "SearxNG", + description: "Search with SearxNG", + connectorType: EnumConnectorName.SEARXNG_API, + }, + { + id: "linkup-api", + title: "Linkup API", + description: "Search with Linkup", + connectorType: EnumConnectorName.LINKUP_API, + }, + { + id: "baidu-search-api", + title: "Baidu Search", + description: "Search with Baidu", + connectorType: EnumConnectorName.BAIDU_SEARCH_API, + }, +] as const; + +// Type for the indexing configuration state +export interface IndexingConfigState { + connectorType: string; + connectorId: number; + connectorTitle: string; +} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx new file mode 100644 index 000000000..d4126f2ce --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Search } from "lucide-react"; +import { type FC } from "react"; +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +interface ConnectorDialogHeaderProps { + activeTab: string; + totalSourceCount: number; + searchQuery: string; + onTabChange: (value: string) => void; + onSearchChange: (query: string) => void; + isScrolled: boolean; +} + +export const ConnectorDialogHeader: FC = ({ + activeTab, + totalSourceCount, + searchQuery, + onTabChange, + onSearchChange, + isScrolled, +}) => { + return ( +
+ + + Connectors + + + Search across all your apps and data in one place. + + + +
+ + + All Connectors + + + + Active + + + {totalSourceCount > 0 && ( + + {totalSourceCount} + + )} + + + +
+
+ + onSearchChange(e.target.value)} + /> +
+
+
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx b/surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx new file mode 100644 index 000000000..1112f3f36 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { format, subDays, subYears } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; +import type { FC } from "react"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface DateRangeSelectorProps { + startDate: Date | undefined; + endDate: Date | undefined; + onStartDateChange: (date: Date | undefined) => void; + onEndDateChange: (date: Date | undefined) => void; +} + +export const DateRangeSelector: FC = ({ + startDate, + endDate, + onStartDateChange, + onEndDateChange, +}) => { + const handleLast30Days = () => { + const today = new Date(); + onStartDateChange(subDays(today, 30)); + onEndDateChange(today); + }; + + const handleLastYear = () => { + const today = new Date(); + onStartDateChange(subYears(today, 1)); + onEndDateChange(today); + }; + + const handleClearDates = () => { + onStartDateChange(undefined); + onEndDateChange(undefined); + }; + + return ( +
+

Select Date Range

+

+ Choose how far back you want to sync your data. You can always re-index later with different dates. +

+ +
+ {/* Start Date */} +
+ + + + + + + date > new Date()} + /> + + +
+ + {/* End Date */} +
+ + + + + + + date > new Date() || (startDate ? date < startDate : false)} + /> + + +
+
+ + {/* Quick date range buttons */} +
+ + + +
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/index.ts b/surfsense_web/components/assistant-ui/connector-popup/index.ts new file mode 100644 index 000000000..da1d4639e --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/index.ts @@ -0,0 +1,19 @@ +// Main component export +export { ConnectorIndicator } from "../connector-popup"; + +// Sub-components (if needed for external use) +export { ConnectorCard } from "./connector-card"; +export { DateRangeSelector } from "./date-range-selector"; +export { PeriodicSyncConfig } from "./periodic-sync-config"; +export { IndexingConfigurationView } from "./indexing-configuration-view"; +export { ConnectorDialogHeader } from "./connector-dialog-header"; +export { AllConnectorsTab } from "./all-connectors-tab"; +export { ActiveConnectorsTab } from "./active-connectors-tab"; + +// Constants and types +export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; +export type { IndexingConfigState } from "./connector-constants"; + +// Hooks +export { useConnectorDialog } from "./use-connector-dialog"; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx new file mode 100644 index 000000000..cb9c3b66b --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { ArrowLeft, Check, Loader2 } from "lucide-react"; +import { type FC } from "react"; +import { Button } from "@/components/ui/button"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { IndexingConfigState } from "./connector-constants"; +import { DateRangeSelector } from "./date-range-selector"; +import { PeriodicSyncConfig } from "./periodic-sync-config"; + +interface IndexingConfigurationViewProps { + config: IndexingConfigState; + startDate: Date | undefined; + endDate: Date | undefined; + periodicEnabled: boolean; + frequencyMinutes: string; + isStartingIndexing: boolean; + onStartDateChange: (date: Date | undefined) => void; + onEndDateChange: (date: Date | undefined) => void; + onPeriodicEnabledChange: (enabled: boolean) => void; + onFrequencyChange: (frequency: string) => void; + onStartIndexing: () => void; + onSkip: () => void; +} + +export const IndexingConfigurationView: FC = ({ + config, + startDate, + endDate, + periodicEnabled, + frequencyMinutes, + isStartingIndexing, + onStartDateChange, + onEndDateChange, + onPeriodicEnabledChange, + onFrequencyChange, + onStartIndexing, + onSkip, +}) => { + return ( +
+ {/* Fixed Header */} +
+ {/* Back button */} + + + {/* Success header */} +
+
+ +
+
+

+ {config.connectorTitle} Connected! +

+

+ Configure when to start syncing your data +

+
+
+
+ + {/* Scrollable Content */} +
+
+ + + + + {/* Info box */} +
+
+ {getConnectorIcon(config.connectorType, "size-4")} +
+
+

Indexing runs in the background

+

+ You can continue using SurfSense while we sync your data. Check the Active tab to see progress. +

+
+
+
+
+ + {/* Fixed Footer - Action buttons */} +
+ + +
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx new file mode 100644 index 000000000..427a6ac86 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { type FC } from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface PeriodicSyncConfigProps { + enabled: boolean; + frequencyMinutes: string; + onEnabledChange: (enabled: boolean) => void; + onFrequencyChange: (frequency: string) => void; +} + +export const PeriodicSyncConfig: FC = ({ + enabled, + frequencyMinutes, + onEnabledChange, + onFrequencyChange, +}) => { + return ( +
+
+
+

Enable Periodic Sync

+

+ Automatically re-index at regular intervals +

+
+ +
+ + {enabled && ( +
+
+ + +
+
+ )} +
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts new file mode 100644 index 000000000..5a309c2ce --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts @@ -0,0 +1,295 @@ +import { useAtomValue } from "jotai"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms"; +import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { authenticatedFetch } from "@/lib/auth-utils"; +import { queryClient } from "@/lib/query-client/client"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { format } from "date-fns"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { OAUTH_CONNECTORS } from "./connector-constants"; +import type { IndexingConfigState } from "./connector-constants"; + +export const useConnectorDialog = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); + const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); + const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); + + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState("all"); + const [connectingId, setConnectingId] = useState(null); + const [isScrolled, setIsScrolled] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [indexingConfig, setIndexingConfig] = useState(null); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [isStartingIndexing, setIsStartingIndexing] = useState(false); + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); + + // Helper function to get frequency label + const getFrequencyLabel = useCallback((minutes: string): string => { + switch (minutes) { + case "15": return "15 minutes"; + case "60": return "hour"; + case "360": return "6 hours"; + case "720": return "12 hours"; + case "1440": return "day"; + case "10080": return "week"; + default: return `${minutes} minutes`; + } + }, []); + + // Synchronize state with URL query params + useEffect(() => { + const modalParam = searchParams.get("modal"); + const tabParam = searchParams.get("tab"); + const viewParam = searchParams.get("view"); + const connectorParam = searchParams.get("connector"); + + if (modalParam === "connectors") { + if (!isOpen) setIsOpen(true); + + if (tabParam === "active" || tabParam === "all") { + if (activeTab !== tabParam) setActiveTab(tabParam); + } + + if (viewParam === "configure" && connectorParam && !indexingConfig) { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); + if (oauthConnector && allConnectors) { + const existingConnector = allConnectors.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (existingConnector) { + setIndexingConfig({ + connectorType: oauthConnector.connectorType, + connectorId: existingConnector.id, + connectorTitle: oauthConnector.title, + }); + } + } + } + } else { + if (isOpen) setIsOpen(false); + } + }, [searchParams, isOpen, activeTab, indexingConfig, allConnectors]); + + // Detect OAuth success and transition to config view + useEffect(() => { + const success = searchParams.get("success"); + const connectorParam = searchParams.get("connector"); + const modalParam = searchParams.get("modal"); + + if (success === "true" && connectorParam && searchSpaceId && modalParam === "connectors") { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); + if (oauthConnector) { + refetchAllConnectors().then((result) => { + const newConnector = result.data?.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (newConnector) { + setIndexingConfig({ + connectorType: oauthConnector.connectorType, + connectorId: newConnector.id, + connectorTitle: oauthConnector.title, + }); + setIsOpen(true); + const url = new URL(window.location.href); + url.searchParams.delete("success"); + url.searchParams.set("view", "configure"); + window.history.replaceState({}, "", url.toString()); + } + }); + } + } + }, [searchParams, searchSpaceId, refetchAllConnectors]); + + // Handle OAuth connection + const handleConnectOAuth = useCallback( + async (connector: (typeof OAUTH_CONNECTORS)[0]) => { + if (!searchSpaceId || !connector.authEndpoint) return; + + try { + setConnectingId(connector.id); + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`Failed to initiate ${connector.title} OAuth`); + } + + const data = await response.json(); + window.location.href = data.auth_url; + } catch (error) { + console.error(`Error connecting to ${connector.title}:`, error); + toast.error(`Failed to connect to ${connector.title}`); + } finally { + setConnectingId(null); + } + }, + [searchSpaceId] + ); + + // Handle starting indexing + const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => { + if (!indexingConfig || !searchSpaceId) return; + + setIsStartingIndexing(true); + try { + const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; + const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; + + if (periodicEnabled) { + const frequency = parseInt(frequencyMinutes, 10); + await updateConnector({ + id: indexingConfig.connectorId, + data: { + periodic_indexing_enabled: true, + indexing_frequency_minutes: frequency, + }, + }); + } + + await indexConnector({ + connector_id: indexingConfig.connectorId, + queryParams: { + search_space_id: searchSpaceId, + start_date: startDateStr, + end_date: endDateStr, + }, + }); + + toast.success(`${indexingConfig.connectorTitle} indexing started`, { + description: periodicEnabled + ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` + : "You can continue working while we sync your data.", + }); + + setIndexingConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + + const url = new URL(window.location.href); + url.searchParams.delete("view"); + url.searchParams.delete("connector"); + url.searchParams.set("tab", "active"); + window.history.replaceState({}, "", url.toString()); + setActiveTab("active"); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error starting indexing:", error); + toast.error("Failed to start indexing"); + } finally { + setIsStartingIndexing(false); + } + }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel]); + + // Handle skipping indexing + const handleSkipIndexing = useCallback(() => { + setIndexingConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + + const url = new URL(window.location.href); + url.searchParams.delete("view"); + url.searchParams.delete("connector"); + window.history.replaceState({}, "", url.toString()); + }, []); + + // Handle dialog open/close + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + + if (open) { + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", activeTab); + window.history.pushState({ modal: true }, "", url.toString()); + } else { + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + window.history.pushState({ modal: false }, "", url.toString()); + setIsScrolled(false); + setSearchQuery(""); + if (!isStartingIndexing) { + setIndexingConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + } + } + }, + [activeTab, isStartingIndexing] + ); + + // Handle tab change + 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 + const handleScroll = useCallback((e: React.UIEvent) => { + setIsScrolled(e.currentTarget.scrollTop > 0); + }, []); + + return { + // State + isOpen, + activeTab, + connectingId, + isScrolled, + searchQuery, + indexingConfig, + startDate, + endDate, + isStartingIndexing, + periodicEnabled, + frequencyMinutes, + searchSpaceId, + allConnectors, + + // Setters + setSearchQuery, + setStartDate, + setEndDate, + setPeriodicEnabled, + setFrequencyMinutes, + + // Handlers + handleOpenChange, + handleTabChange, + handleScroll, + handleConnectOAuth, + handleStartIndexing, + handleSkipIndexing, + }; +}; + From 29a3dcf0916db3c6d78e9e41942bce310506de95 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 30 Dec 2025 21:25:48 +0530 Subject: [PATCH 08/28] feat: Introduce new connector schemas and validation, enhance connector dialog with improved query parameter handling, and implement scroll detection in indexing configuration view. --- .../connector-popup/active-connectors-tab.tsx | 5 +- .../connector-popup/all-connectors-tab.tsx | 45 ++-- .../connector-popup/connector-card.tsx | 6 - .../connector-popup/connector-constants.ts | 15 +- .../connector-popup.schemas.ts | 108 +++++++++ .../assistant-ui/connector-popup/index.ts | 18 ++ .../indexing-configuration-view.tsx | 108 ++++++--- .../connector-popup/use-connector-dialog.ts | 214 ++++++++++++------ 8 files changed, 376 insertions(+), 143 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-popup.schemas.ts diff --git a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx index 323fa34e7..43deba278 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import type { LogSummary, LogActiveTask } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; import { TabsContent, @@ -20,7 +21,7 @@ interface ActiveConnectorsTabProps { activeDocumentTypes: Array<[string, number]>; connectors: SearchSourceConnector[]; indexingConnectorIds: Set; - logsSummary: any; + logsSummary: LogSummary | undefined; searchSpaceId: string; onTabChange: (value: string) => void; } @@ -67,7 +68,7 @@ export const ActiveConnectorsTab: FC = ({ {connectors.map((connector) => { const isIndexing = indexingConnectorIds.has(connector.id); const activeTask = logsSummary?.active_tasks?.find( - (task: any) => + (task: LogActiveTask) => task.source?.includes(`connector_${connector.id}`) || task.source?.includes(`connector-${connector.id}`) ); diff --git a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx index e2f735b13..4dd056c90 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx @@ -1,10 +1,7 @@ "use client"; -import { ChevronRight } from "lucide-react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { type FC } from "react"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; import { ConnectorCard } from "./connector-card"; @@ -88,32 +85,24 @@ export const AllConnectorsTab: FC = ({ const isConnected = connectedTypes.has(connector.connectorType); return ( - -
- {getConnectorIcon(connector.connectorType, "size-6")} -
-
-
- - {connector.title} - - {isConnected && ( - - )} -
-

- {connector.description} -

-
- - + id={connector.id} + title={connector.title} + description={connector.description} + connectorType={connector.connectorType} + isConnected={isConnected} + onConnect={() => + router.push( + `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` + ) + } + onManage={() => + router.push( + `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` + ) + } + /> ); })}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx index d1f79b16d..1e5871579 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx @@ -34,12 +34,6 @@ export const ConnectorCard: FC = ({
{title} - {isConnected && ( - - )}

{isConnected ? "Connected" : description} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts index 65d5bd516..a2750e133 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts @@ -2,6 +2,13 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; // OAuth Connectors (Quick Connect) export const OAUTH_CONNECTORS = [ + { + id: "google-drive-connector", + title: "Google Drive", + description: "Search your Drive files", + connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR, + authEndpoint: "/api/v1/auth/google/drive/connector/add/", + }, { id: "google-gmail-connector", title: "Gmail", @@ -125,10 +132,6 @@ export const OTHER_CONNECTORS = [ }, ] as const; -// Type for the indexing configuration state -export interface IndexingConfigState { - connectorType: string; - connectorId: number; - connectorTitle: string; -} +// Re-export IndexingConfigState from schemas for backward compatibility +export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-popup.schemas.ts b/surfsense_web/components/assistant-ui/connector-popup/connector-popup.schemas.ts new file mode 100644 index 000000000..118625e57 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-popup.schemas.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types"; + +/** + * Schema for URL query parameters used by the connector popup + */ +export const connectorPopupQueryParamsSchema = z.object({ + modal: z.enum(["connectors"]).optional(), + tab: z.enum(["all", "active"]).optional(), + view: z.enum(["configure"]).optional(), + connector: z.string().optional(), + success: z.enum(["true", "false"]).optional(), +}); + +export type ConnectorPopupQueryParams = z.infer; + +/** + * Schema for OAuth API response (auth_url) + */ +export const oauthAuthResponseSchema = z.object({ + auth_url: z.string().url("Invalid auth URL format"), +}); + +export type OAuthAuthResponse = z.infer; + +/** + * Schema for IndexingConfigState + */ +export const indexingConfigStateSchema = z.object({ + connectorType: searchSourceConnectorTypeEnum, + connectorId: z.number().int().positive("Connector ID must be a positive integer"), + connectorTitle: z.string().min(1, "Connector title is required"), +}); + +export type IndexingConfigState = z.infer; + +/** + * Schema for frequency minutes (must be one of the allowed values) + */ +export const frequencyMinutesSchema = z.enum(["15", "60", "360", "720", "1440", "10080"], { + errorMap: () => ({ message: "Invalid frequency value" }), +}); + +export type FrequencyMinutes = z.infer; + +/** + * Schema for date range validation + */ +export const dateRangeSchema = z + .object({ + startDate: z.date().optional(), + endDate: z.date().optional(), + }) + .refine( + (data) => { + if (data.startDate && data.endDate) { + return data.startDate <= data.endDate; + } + return true; + }, + { + message: "Start date must be before or equal to end date", + path: ["endDate"], + } + ); + +export type DateRange = z.infer; + +/** + * Schema for connector ID validation (used in URL params) + */ +export const connectorIdSchema = z.string().min(1, "Connector ID is required"); + +/** + * Helper function to safely parse query params + */ +export function parseConnectorPopupQueryParams( + params: URLSearchParams | Record +): ConnectorPopupQueryParams { + const obj: Record = {}; + + if (params instanceof URLSearchParams) { + params.forEach((value, key) => { + obj[key] = value || undefined; + }); + } else { + Object.entries(params).forEach(([key, value]) => { + obj[key] = value || undefined; + }); + } + + return connectorPopupQueryParamsSchema.parse(obj); +} + +/** + * Helper function to safely parse OAuth response + */ +export function parseOAuthAuthResponse(data: unknown): OAuthAuthResponse { + return oauthAuthResponseSchema.parse(data); +} + +/** + * Helper function to validate indexing config state + */ +export function validateIndexingConfigState(data: unknown): IndexingConfigState { + return indexingConfigStateSchema.parse(data); +} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/index.ts b/surfsense_web/components/assistant-ui/connector-popup/index.ts index da1d4639e..1aea4de95 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/index.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/index.ts @@ -14,6 +14,24 @@ export { ActiveConnectorsTab } from "./active-connectors-tab"; export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; export type { IndexingConfigState } from "./connector-constants"; +// Schemas and validation +export { + connectorPopupQueryParamsSchema, + oauthAuthResponseSchema, + indexingConfigStateSchema, + frequencyMinutesSchema, + dateRangeSchema, + parseConnectorPopupQueryParams, + parseOAuthAuthResponse, + validateIndexingConfigState, +} from "./connector-popup.schemas"; +export type { + ConnectorPopupQueryParams, + OAuthAuthResponse, + FrequencyMinutes, + DateRange, +} from "./connector-popup.schemas"; + // Hooks export { useConnectorDialog } from "./use-connector-dialog"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx index cb9c3b66b..3135c634b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx @@ -1,9 +1,10 @@ "use client"; import { ArrowLeft, Check, Loader2 } from "lucide-react"; -import { type FC } from "react"; +import { type FC, useState, useCallback, useRef, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { cn } from "@/lib/utils"; import type { IndexingConfigState } from "./connector-constants"; import { DateRangeSelector } from "./date-range-selector"; import { PeriodicSyncConfig } from "./periodic-sync-config"; @@ -37,10 +38,49 @@ export const IndexingConfigurationView: FC = ({ onStartIndexing, onSkip, }) => { + const [isScrolled, setIsScrolled] = useState(false); + const [hasMoreContent, setHasMoreContent] = useState(false); + const scrollContainerRef = useRef(null); + + const checkScrollState = useCallback(() => { + if (!scrollContainerRef.current) return; + + const target = scrollContainerRef.current; + const scrolled = target.scrollTop > 0; + const hasMore = target.scrollHeight > target.clientHeight && + target.scrollTop + target.clientHeight < target.scrollHeight - 10; + + setIsScrolled(scrolled); + setHasMoreContent(hasMore); + }, []); + + const handleScroll = useCallback(() => { + checkScrollState(); + }, [checkScrollState]); + + // Check initial scroll state and on resize + useEffect(() => { + checkScrollState(); + const resizeObserver = new ResizeObserver(() => { + checkScrollState(); + }); + + if (scrollContainerRef.current) { + resizeObserver.observe(scrollContainerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [checkScrollState]); + return (

{/* Fixed Header */} -
+
{/* Back button */}
{/* Scrollable Content */} -
-
- +
+
+
+ - + - {/* Info box */} -
-
- {getConnectorIcon(config.connectorType, "size-4")} -
-
-

Indexing runs in the background

-

- You can continue using SurfSense while we sync your data. Check the Active tab to see progress. -

+ {/* Info box */} +
+
+ {getConnectorIcon(config.connectorType, "size-4")} +
+
+

Indexing runs in the background

+

+ You can continue using SurfSense while we sync your data. Check the Active tab to see progress. +

+
+ {/* Top fade shadow - appears when scrolled */} + {isScrolled && ( +
+ )} + {/* Bottom fade shadow - appears when there's more content */} + {hasMoreContent && ( +
+ )}
{/* Fixed Footer - Action buttons */} -
+
diff --git a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts index 5a309c2ce..32a475300 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts @@ -10,8 +10,16 @@ import { queryClient } from "@/lib/query-client/client"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { format } from "date-fns"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { searchSourceConnector } from "@/contracts/types/connector.types"; import { OAUTH_CONNECTORS } from "./connector-constants"; import type { IndexingConfigState } from "./connector-constants"; +import { + parseConnectorPopupQueryParams, + parseOAuthAuthResponse, + validateIndexingConfigState, + frequencyMinutesSchema, + dateRangeSchema, +} from "./connector-popup.schemas"; export const useConnectorDialog = () => { const router = useRouter(); @@ -48,65 +56,101 @@ export const useConnectorDialog = () => { // Synchronize state with URL query params useEffect(() => { - const modalParam = searchParams.get("modal"); - const tabParam = searchParams.get("tab"); - const viewParam = searchParams.get("view"); - const connectorParam = searchParams.get("connector"); - - if (modalParam === "connectors") { - if (!isOpen) setIsOpen(true); + try { + const params = parseConnectorPopupQueryParams(searchParams); - if (tabParam === "active" || tabParam === "all") { - if (activeTab !== tabParam) setActiveTab(tabParam); - } - - if (viewParam === "configure" && connectorParam && !indexingConfig) { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); - if (oauthConnector && allConnectors) { - const existingConnector = allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (existingConnector) { - setIndexingConfig({ - connectorType: oauthConnector.connectorType, - connectorId: existingConnector.id, - connectorTitle: oauthConnector.title, - }); + if (params.modal === "connectors") { + setIsOpen(true); + + if (params.tab === "active" || params.tab === "all") { + setActiveTab(params.tab); + } + + // Clear indexing config if view is not "configure" anymore + if (params.view !== "configure" && indexingConfig) { + setIndexingConfig(null); + } + + if (params.view === "configure" && params.connector && !indexingConfig) { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); + if (oauthConnector && allConnectors) { + const existingConnector = allConnectors.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (existingConnector) { + // Validate connector data before setting state + const connectorValidation = searchSourceConnector.safeParse(existingConnector); + if (connectorValidation.success) { + const config = validateIndexingConfigState({ + connectorType: oauthConnector.connectorType, + connectorId: existingConnector.id, + connectorTitle: oauthConnector.title, + }); + setIndexingConfig(config); + } + } } } + } else { + setIsOpen(false); + // Clear indexing config when modal is closed + if (indexingConfig) { + setIndexingConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setIsScrolled(false); + setSearchQuery(""); + } } - } else { - if (isOpen) setIsOpen(false); + } catch (error) { + // Invalid query params - log but don't crash + console.warn("Invalid connector popup query params:", error); } - }, [searchParams, isOpen, activeTab, indexingConfig, allConnectors]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, allConnectors]); // Detect OAuth success and transition to config view useEffect(() => { - const success = searchParams.get("success"); - const connectorParam = searchParams.get("connector"); - const modalParam = searchParams.get("modal"); - - if (success === "true" && connectorParam && searchSpaceId && modalParam === "connectors") { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); - if (oauthConnector) { - refetchAllConnectors().then((result) => { - const newConnector = result.data?.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (newConnector) { - setIndexingConfig({ - connectorType: oauthConnector.connectorType, - connectorId: newConnector.id, - connectorTitle: oauthConnector.title, - }); - setIsOpen(true); - const url = new URL(window.location.href); - url.searchParams.delete("success"); - url.searchParams.set("view", "configure"); - window.history.replaceState({}, "", url.toString()); - } - }); + try { + const params = parseConnectorPopupQueryParams(searchParams); + + if (params.success === "true" && params.connector && searchSpaceId && params.modal === "connectors") { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); + if (oauthConnector) { + refetchAllConnectors().then((result) => { + if (!result.data) return; + + const newConnector = result.data.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (newConnector) { + // Validate connector data before setting state + const connectorValidation = searchSourceConnector.safeParse(newConnector); + if (connectorValidation.success) { + const config = validateIndexingConfigState({ + connectorType: oauthConnector.connectorType, + connectorId: newConnector.id, + connectorTitle: oauthConnector.title, + }); + setIndexingConfig(config); + setIsOpen(true); + const url = new URL(window.location.href); + url.searchParams.delete("success"); + url.searchParams.set("view", "configure"); + window.history.replaceState({}, "", url.toString()); + } else { + console.warn("Invalid connector data after OAuth:", connectorValidation.error); + toast.error("Failed to validate connector data"); + } + } + }); + } } + } catch (error) { + // Invalid query params - log but don't crash + console.warn("Invalid connector popup query params in OAuth success handler:", error); } }, [searchParams, searchSpaceId, refetchAllConnectors]); @@ -115,8 +159,10 @@ export const useConnectorDialog = () => { async (connector: (typeof OAUTH_CONNECTORS)[0]) => { if (!searchSpaceId || !connector.authEndpoint) return; + // Set connecting state immediately to disable button and show spinner + setConnectingId(connector.id); + try { - setConnectingId(connector.id); const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, { method: "GET" } @@ -127,11 +173,21 @@ export const useConnectorDialog = () => { } const data = await response.json(); - window.location.href = data.auth_url; + + // Validate OAuth response with Zod + const validatedData = parseOAuthAuthResponse(data); + + // Don't clear connectingId here - let the redirect happen with button still disabled + // The component will unmount on redirect anyway + window.location.href = validatedData.auth_url; } catch (error) { console.error(`Error connecting to ${connector.title}:`, error); - toast.error(`Failed to connect to ${connector.title}`); - } finally { + if (error instanceof Error && error.message.includes("Invalid auth URL")) { + toast.error(`Invalid response from ${connector.title} OAuth endpoint`); + } else { + toast.error(`Failed to connect to ${connector.title}`); + } + // Only clear connectingId on error so user can retry setConnectingId(null); } }, @@ -142,6 +198,22 @@ export const useConnectorDialog = () => { const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => { if (!indexingConfig || !searchSpaceId) return; + // Validate date range + const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); + if (!dateRangeValidation.success) { + toast.error(dateRangeValidation.error.errors[0]?.message || "Invalid date range"); + return; + } + + // Validate frequency minutes if periodic is enabled + if (periodicEnabled) { + const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); + if (!frequencyValidation.success) { + toast.error("Invalid frequency value"); + return; + } + } + setIsStartingIndexing(true); try { const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; @@ -173,18 +245,14 @@ export const useConnectorDialog = () => { : "You can continue working while we sync your data.", }); - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - + // Update URL - the effect will handle closing the modal and clearing state const url = new URL(window.location.href); - url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); url.searchParams.delete("connector"); - url.searchParams.set("tab", "active"); - window.history.replaceState({}, "", url.toString()); - setActiveTab("active"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); refreshConnectors(); queryClient.invalidateQueries({ @@ -196,21 +264,19 @@ export const useConnectorDialog = () => { } finally { setIsStartingIndexing(false); } - }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel]); + }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router]); // Handle skipping indexing const handleSkipIndexing = useCallback(() => { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - + // Update URL - the effect will handle closing the modal and clearing state const url = new URL(window.location.href); - url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); url.searchParams.delete("connector"); - window.history.replaceState({}, "", url.toString()); - }, []); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); // Handle dialog open/close const handleOpenChange = useCallback( From ddfbb9509b405bf994b0d476729577523e053437 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:00:11 +0530 Subject: [PATCH 09/28] feat: Implement connector editing functionality in the popup, including Google Drive folder selection, and enhance connector management with improved state handling and UI updates. --- .../google_drive_add_connector_route.py | 3 +- .../assistant-ui/connector-popup.tsx | 56 +- .../{ => components}/connector-card.tsx | 0 .../connector-dialog-header.tsx | 0 .../{ => components}/date-range-selector.tsx | 0 .../{ => components}/periodic-sync-config.tsx | 0 .../components/google-drive-config.tsx | 103 +++ .../connector-configs/index.tsx | 28 + .../views/connector-edit-view.tsx | 247 +++++++ .../views}/indexing-configuration-view.tsx | 42 +- .../{ => constants}/connector-constants.ts | 2 +- .../connector-popup.schemas.ts | 3 +- .../hooks/use-connector-dialog.ts | 637 ++++++++++++++++++ .../assistant-ui/connector-popup/index.ts | 25 +- .../{ => tabs}/active-connectors-tab.tsx | 8 +- .../{ => tabs}/all-connectors-tab.tsx | 29 +- .../connector-popup/use-connector-dialog.ts | 361 ---------- .../connectors/google-drive-folder-tree.tsx | 80 ++- surfsense_web/components/ui/switch.tsx | 4 +- 19 files changed, 1182 insertions(+), 446 deletions(-) rename surfsense_web/components/assistant-ui/connector-popup/{ => components}/connector-card.tsx (100%) rename surfsense_web/components/assistant-ui/connector-popup/{ => components}/connector-dialog-header.tsx (100%) rename surfsense_web/components/assistant-ui/connector-popup/{ => components}/date-range-selector.tsx (100%) rename surfsense_web/components/assistant-ui/connector-popup/{ => components}/periodic-sync-config.tsx (100%) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx rename surfsense_web/components/assistant-ui/connector-popup/{ => connector-configs/views}/indexing-configuration-view.tsx (79%) rename surfsense_web/components/assistant-ui/connector-popup/{ => constants}/connector-constants.ts (98%) rename surfsense_web/components/assistant-ui/connector-popup/{ => constants}/connector-popup.schemas.ts (97%) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts rename surfsense_web/components/assistant-ui/connector-popup/{ => tabs}/active-connectors-tab.tsx (97%) rename surfsense_web/components/assistant-ui/connector-popup/{ => tabs}/all-connectors-tab.tsx (72%) delete mode 100644 surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts diff --git a/surfsense_backend/app/routes/google_drive_add_connector_route.py b/surfsense_backend/app/routes/google_drive_add_connector_route.py index d11404781..73ad76409 100644 --- a/surfsense_backend/app/routes/google_drive_add_connector_route.py +++ b/surfsense_backend/app/routes/google_drive_add_connector_route.py @@ -208,9 +208,8 @@ async def drive_callback( f"Successfully created Google Drive connector {db_connector.id} for user {user_id}" ) - # Redirect to connectors management page (not to folder selection) return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors?success=google-drive-connected" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-drive-connector" ) except HTTPException: diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 1e9e09869..c1ed38e7b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -17,11 +17,12 @@ import { } from "@/components/ui/tabs"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; -import { AllConnectorsTab } from "./connector-popup/all-connectors-tab"; -import { ActiveConnectorsTab } from "./connector-popup/active-connectors-tab"; -import { ConnectorDialogHeader } from "./connector-popup/connector-dialog-header"; -import { IndexingConfigurationView } from "./connector-popup/indexing-configuration-view"; -import { useConnectorDialog } from "./connector-popup/use-connector-dialog"; +import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; +import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab"; +import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; +import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; +import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view"; +import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; export const ConnectorIndicator: FC = () => { @@ -67,9 +68,14 @@ export const ConnectorIndicator: FC = () => { isScrolled, searchQuery, indexingConfig, + indexingConnector, + indexingConnectorConfig, + editingConnector, startDate, endDate, isStartingIndexing, + isSaving, + isDisconnecting, periodicEnabled, frequencyMinutes, allConnectors, @@ -84,6 +90,13 @@ export const ConnectorIndicator: FC = () => { handleConnectOAuth, handleStartIndexing, handleSkipIndexing, + handleStartEdit, + handleSaveConnector, + handleDisconnectConnector, + handleBackFromEdit, + connectorConfig, + setConnectorConfig, + setIndexingConnectorConfig, } = useConnectorDialog(); // Get document types that have documents in the search space @@ -133,10 +146,35 @@ export const ConnectorIndicator: FC = () => { - {/* Indexing Configuration View - shown after OAuth success */} - {indexingConfig ? ( + {/* Connector Edit View - shown when editing existing connector */} + {editingConnector ? ( + handleSaveConnector(refreshConnectors)} + onDisconnect={() => handleDisconnectConnector(refreshConnectors)} + onBack={handleBackFromEdit} + onConfigChange={setConnectorConfig} + /> + ) : indexingConfig ? ( { onEndDateChange={setEndDate} onPeriodicEnabledChange={setPeriodicEnabled} onFrequencyChange={setFrequencyMinutes} + onConfigChange={setIndexingConnectorConfig} onStartIndexing={() => handleStartIndexing(refreshConnectors)} onSkip={handleSkipIndexing} /> @@ -171,7 +210,9 @@ export const ConnectorIndicator: FC = () => { searchSpaceId={searchSpaceId} connectedTypes={connectedTypes} connectingId={connectingId} + allConnectors={allConnectors} onConnectOAuth={handleConnectOAuth} + onManage={handleStartEdit} /> @@ -184,6 +225,7 @@ export const ConnectorIndicator: FC = () => { logsSummary={logsSummary} searchSpaceId={searchSpaceId} onTabChange={handleTabChange} + onManage={handleStartEdit} />
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx new file mode 100644 index 000000000..280d6ed23 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Info } from "lucide-react"; +import { useState, useEffect } from "react"; +import type { FC } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree"; +import type { ConnectorConfigProps } from "../index"; + +interface SelectedFolder { + id: string; + name: string; +} + +export const GoogleDriveConfig: FC = ({ + connector, + onConfigChange, +}) => { + // Initialize with existing selected folders from connector config + const existingFolders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; + const [selectedFolders, setSelectedFolders] = useState(existingFolders); + const [showFolderSelector, setShowFolderSelector] = useState(false); + + // Update selected folders when connector config changes + useEffect(() => { + const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; + setSelectedFolders(folders); + }, [connector.config]); + + const handleSelectFolders = (folders: SelectedFolder[]) => { + setSelectedFolders(folders); + if (onConfigChange) { + // Store folder IDs and names in config for indexing + onConfigChange({ + ...connector.config, + selected_folders: folders, + }); + } + }; + + return ( +
+
+

Folder Selection

+

+ Select specific folders to index. Only files directly in each folder will be processed—subfolders must be selected separately. +

+
+ + {selectedFolders.length > 0 && ( +
+

+ Selected {selectedFolders.length} folder{selectedFolders.length > 1 ? "s" : ""}: +

+
+ {selectedFolders.map((folder) => ( +

+ • {folder.name} +

+ ))} +
+
+ )} + + {showFolderSelector ? ( +
+ + +
+ ) : ( + + )} + + + + + Folder selection is used when indexing. You can change this selection when you start indexing. + + +
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx new file mode 100644 index 000000000..eb2594ad6 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx @@ -0,0 +1,28 @@ +"use client"; + +import type { FC } from "react"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { GoogleDriveConfig } from "./components/google-drive-config"; + +export interface ConnectorConfigProps { + connector: SearchSourceConnector; + onConfigChange?: (config: Record) => void; +} + +export type ConnectorConfigComponent = FC; + +/** + * Factory function to get the appropriate config component for a connector type + */ +export function getConnectorConfigComponent( + connectorType: string +): ConnectorConfigComponent | null { + switch (connectorType) { + case "GOOGLE_DRIVE_CONNECTOR": + return GoogleDriveConfig; + // OAuth connectors (Gmail, Calendar, Airtable) and others don't need special config UI + default: + return null; + } +} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx new file mode 100644 index 000000000..cc4f3a815 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { ArrowLeft, Loader2, Trash2 } from "lucide-react"; +import { type FC, useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { cn } from "@/lib/utils"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; +import { getConnectorConfigComponent } from "../index"; + +interface ConnectorEditViewProps { + connector: SearchSourceConnector; + startDate: Date | undefined; + endDate: Date | undefined; + periodicEnabled: boolean; + frequencyMinutes: string; + isSaving: boolean; + isDisconnecting: boolean; + onStartDateChange: (date: Date | undefined) => void; + onEndDateChange: (date: Date | undefined) => void; + onPeriodicEnabledChange: (enabled: boolean) => void; + onFrequencyChange: (frequency: string) => void; + onSave: () => void; + onDisconnect: () => void; + onBack: () => void; + onConfigChange?: (config: Record) => void; +} + +export const ConnectorEditView: FC = ({ + connector, + startDate, + endDate, + periodicEnabled, + frequencyMinutes, + isSaving, + isDisconnecting, + onStartDateChange, + onEndDateChange, + onPeriodicEnabledChange, + onFrequencyChange, + onSave, + onDisconnect, + onBack, + onConfigChange, +}) => { + // Get connector-specific config component + const ConnectorConfigComponent = useMemo( + () => getConnectorConfigComponent(connector.connector_type), + [connector.connector_type] + ); + const [isScrolled, setIsScrolled] = useState(false); + const [hasMoreContent, setHasMoreContent] = useState(false); + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + const scrollContainerRef = useRef(null); + + const checkScrollState = useCallback(() => { + if (!scrollContainerRef.current) return; + + const target = scrollContainerRef.current; + const scrolled = target.scrollTop > 0; + const hasMore = target.scrollHeight > target.clientHeight && + target.scrollTop + target.clientHeight < target.scrollHeight - 10; + + setIsScrolled(scrolled); + setHasMoreContent(hasMore); + }, []); + + const handleScroll = useCallback(() => { + checkScrollState(); + }, [checkScrollState]); + + // Check initial scroll state and on resize + useEffect(() => { + checkScrollState(); + const resizeObserver = new ResizeObserver(() => { + checkScrollState(); + }); + + if (scrollContainerRef.current) { + resizeObserver.observe(scrollContainerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [checkScrollState]); + + const handleDisconnectClick = () => { + setShowDisconnectConfirm(true); + }; + + const handleDisconnectConfirm = () => { + setShowDisconnectConfirm(false); + onDisconnect(); + }; + + const handleDisconnectCancel = () => { + setShowDisconnectConfirm(false); + }; + + return ( +
+ {/* Fixed Header */} +
+ {/* Back button */} + + + {/* Connector header */} +
+
+ {getConnectorIcon(connector.connector_type, "size-7")} +
+
+

+ {connector.name} +

+

+ Manage your connector settings and sync configuration +

+
+
+
+ + {/* Scrollable Content */} +
+
+
+ {/* Connector-specific configuration */} + {ConnectorConfigComponent && ( + + )} + + {/* Date range selector - not shown for Google Drive (uses folder selection instead) */} + {connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && ( + + )} + + + + {/* Info box */} +
+
+ {getConnectorIcon(connector.connector_type, "size-4")} +
+
+

Re-indexing runs in the background

+

+ You can continue using SurfSense while we sync your data. Check the Active tab to see progress. +

+
+
+
+
+ {/* Top fade shadow - appears when scrolled */} + {isScrolled && ( +
+ )} + {/* Bottom fade shadow - appears when there's more content */} + {hasMoreContent && ( +
+ )} +
+ + {/* Fixed Footer - Action buttons */} +
+ {showDisconnectConfirm ? ( +
+ Are you sure? + + +
+ ) : ( + + )} + +
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx similarity index 79% rename from surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx rename to surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx index 3135c634b..2c11eb415 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx @@ -1,16 +1,19 @@ "use client"; import { ArrowLeft, Check, Loader2 } from "lucide-react"; -import { type FC, useState, useCallback, useRef, useEffect } from "react"; +import { type FC, useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { cn } from "@/lib/utils"; -import type { IndexingConfigState } from "./connector-constants"; -import { DateRangeSelector } from "./date-range-selector"; -import { PeriodicSyncConfig } from "./periodic-sync-config"; +import type { IndexingConfigState } from "../../constants/connector-constants"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; +import { getConnectorConfigComponent } from "../index"; interface IndexingConfigurationViewProps { config: IndexingConfigState; + connector?: SearchSourceConnector; startDate: Date | undefined; endDate: Date | undefined; periodicEnabled: boolean; @@ -20,12 +23,14 @@ interface IndexingConfigurationViewProps { onEndDateChange: (date: Date | undefined) => void; onPeriodicEnabledChange: (enabled: boolean) => void; onFrequencyChange: (frequency: string) => void; + onConfigChange?: (config: Record) => void; onStartIndexing: () => void; onSkip: () => void; } export const IndexingConfigurationView: FC = ({ config, + connector, startDate, endDate, periodicEnabled, @@ -35,9 +40,15 @@ export const IndexingConfigurationView: FC = ({ onEndDateChange, onPeriodicEnabledChange, onFrequencyChange, + onConfigChange, onStartIndexing, onSkip, }) => { + // Get connector-specific config component + const ConnectorConfigComponent = useMemo( + () => connector ? getConnectorConfigComponent(connector.connector_type) : null, + [connector] + ); const [isScrolled, setIsScrolled] = useState(false); const [hasMoreContent, setHasMoreContent] = useState(false); const scrollContainerRef = useRef(null); @@ -115,12 +126,23 @@ export const IndexingConfigurationView: FC = ({ onScroll={handleScroll} >
- + {/* Connector-specific configuration */} + {ConnectorConfigComponent && connector && ( + + )} + + {/* Date range selector - not shown for Google Drive (uses folder selection instead) */} + {config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && ( + + )} { + const router = useRouter(); + const searchParams = useSearchParams(); + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); + const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); + const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); + const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom); + + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState("all"); + const [connectingId, setConnectingId] = useState(null); + const [isScrolled, setIsScrolled] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [indexingConfig, setIndexingConfig] = useState(null); + const [indexingConnector, setIndexingConnector] = useState(null); + const [indexingConnectorConfig, setIndexingConnectorConfig] = useState | null>(null); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [isStartingIndexing, setIsStartingIndexing] = useState(false); + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); + + // Edit mode state + const [editingConnector, setEditingConnector] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [connectorConfig, setConnectorConfig] = useState | null>(null); + + // Helper function to get frequency label + const getFrequencyLabel = useCallback((minutes: string): string => { + switch (minutes) { + case "15": return "15 minutes"; + case "60": return "hour"; + case "360": return "6 hours"; + case "720": return "12 hours"; + case "1440": return "day"; + case "10080": return "week"; + default: return `${minutes} minutes`; + } + }, []); + + // Synchronize state with URL query params + useEffect(() => { + try { + const params = parseConnectorPopupQueryParams(searchParams); + + if (params.modal === "connectors") { + setIsOpen(true); + + if (params.tab === "active" || params.tab === "all") { + setActiveTab(params.tab); + } + + // Clear indexing config if view is not "configure" anymore + if (params.view !== "configure" && indexingConfig) { + setIndexingConfig(null); + } + + // Clear editing connector if view is not "edit" anymore + if (params.view !== "edit" && editingConnector) { + setEditingConnector(null); + } + + if (params.view === "configure" && params.connector && !indexingConfig) { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); + if (oauthConnector && allConnectors) { + const existingConnector = allConnectors.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (existingConnector) { + // Validate connector data before setting state + const connectorValidation = searchSourceConnector.safeParse(existingConnector); + if (connectorValidation.success) { + const config = validateIndexingConfigState({ + connectorType: oauthConnector.connectorType, + connectorId: existingConnector.id, + connectorTitle: oauthConnector.title, + }); + setIndexingConfig(config); + setIndexingConnector(existingConnector); + setIndexingConnectorConfig(existingConnector.config); + } + } + } + } + + // Handle edit view + if (params.view === "edit" && params.connectorId && allConnectors && !editingConnector) { + const connectorId = parseInt(params.connectorId, 10); + const connector = allConnectors.find((c: SearchSourceConnector) => c.id === connectorId); + if (connector) { + const connectorValidation = searchSourceConnector.safeParse(connector); + if (connectorValidation.success) { + setEditingConnector(connector); + setConnectorConfig(connector.config); + // Load existing periodic sync settings + setPeriodicEnabled(connector.periodic_indexing_enabled); + setFrequencyMinutes( + connector.indexing_frequency_minutes?.toString() || "1440" + ); + // Reset dates - user can set new ones for re-indexing + setStartDate(undefined); + setEndDate(undefined); + } + } + } + } else { + setIsOpen(false); + // Clear indexing config when modal is closed + if (indexingConfig) { + setIndexingConfig(null); + setIndexingConnector(null); + setIndexingConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setIsScrolled(false); + setSearchQuery(""); + } + // Clear editing connector when modal is closed + if (editingConnector) { + setEditingConnector(null); + setConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setIsScrolled(false); + setSearchQuery(""); + } + } + } catch (error) { + // Invalid query params - log but don't crash + console.warn("Invalid connector popup query params:", error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, allConnectors, editingConnector, indexingConfig]); + + // Detect OAuth success and transition to config view + useEffect(() => { + try { + const params = parseConnectorPopupQueryParams(searchParams); + + if (params.success === "true" && params.connector && searchSpaceId && params.modal === "connectors") { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); + if (oauthConnector) { + refetchAllConnectors().then((result) => { + if (!result.data) return; + + const newConnector = result.data.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (newConnector) { + // Validate connector data before setting state + const connectorValidation = searchSourceConnector.safeParse(newConnector); + if (connectorValidation.success) { + const config = validateIndexingConfigState({ + connectorType: oauthConnector.connectorType, + connectorId: newConnector.id, + connectorTitle: oauthConnector.title, + }); + setIndexingConfig(config); + setIndexingConnector(newConnector); + setIndexingConnectorConfig(newConnector.config); + setIsOpen(true); + const url = new URL(window.location.href); + url.searchParams.delete("success"); + url.searchParams.set("view", "configure"); + window.history.replaceState({}, "", url.toString()); + } else { + console.warn("Invalid connector data after OAuth:", connectorValidation.error); + toast.error("Failed to validate connector data"); + } + } + }); + } + } + } catch (error) { + // Invalid query params - log but don't crash + console.warn("Invalid connector popup query params in OAuth success handler:", error); + } + }, [searchParams, searchSpaceId, refetchAllConnectors]); + + // Handle OAuth connection + const handleConnectOAuth = useCallback( + async (connector: (typeof OAUTH_CONNECTORS)[0]) => { + if (!searchSpaceId || !connector.authEndpoint) return; + + // Set connecting state immediately to disable button and show spinner + setConnectingId(connector.id); + + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`Failed to initiate ${connector.title} OAuth`); + } + + const data = await response.json(); + + // Validate OAuth response with Zod + const validatedData = parseOAuthAuthResponse(data); + + // Don't clear connectingId here - let the redirect happen with button still disabled + // The component will unmount on redirect anyway + window.location.href = validatedData.auth_url; + } catch (error) { + console.error(`Error connecting to ${connector.title}:`, error); + if (error instanceof Error && error.message.includes("Invalid auth URL")) { + toast.error(`Invalid response from ${connector.title} OAuth endpoint`); + } else { + toast.error(`Failed to connect to ${connector.title}`); + } + // Only clear connectingId on error so user can retry + setConnectingId(null); + } + }, + [searchSpaceId] + ); + + // Handle starting indexing + const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => { + if (!indexingConfig || !searchSpaceId) return; + + // Validate date range + const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); + if (!dateRangeValidation.success) { + const firstIssueMsg = + dateRangeValidation.error.issues && dateRangeValidation.error.issues.length > 0 + ? dateRangeValidation.error.issues[0].message + : "Invalid date range"; + toast.error(firstIssueMsg); + return; + } + + // Validate frequency minutes if periodic is enabled + if (periodicEnabled) { + const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); + if (!frequencyValidation.success) { + toast.error("Invalid frequency value"); + return; + } + } + + setIsStartingIndexing(true); + try { + const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; + const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; + + // Update connector with periodic sync settings and config changes + if (periodicEnabled || indexingConnectorConfig) { + const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined; + await updateConnector({ + id: indexingConfig.connectorId, + data: { + ...(periodicEnabled && { + periodic_indexing_enabled: true, + indexing_frequency_minutes: frequency, + }), + ...(indexingConnectorConfig && { + config: indexingConnectorConfig, + }), + }, + }); + } + + // Handle Google Drive folder selection + if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) { + const selectedFolders = indexingConnectorConfig.selected_folders as Array<{ id: string; name: string }> | undefined; + if (selectedFolders && selectedFolders.length > 0) { + // Index with folder selection + const folderIds = selectedFolders.map((f) => f.id).join(","); + const folderNames = selectedFolders.map((f) => f.name).join(", "); + await indexConnector({ + connector_id: indexingConfig.connectorId, + queryParams: { + search_space_id: searchSpaceId, + folder_ids: folderIds, + folder_names: folderNames, + }, + }); + } else { + // Google Drive requires folder selection - show error if none selected + toast.error("Please select at least one folder to index"); + setIsStartingIndexing(false); + return; + } + } else { + await indexConnector({ + connector_id: indexingConfig.connectorId, + queryParams: { + search_space_id: searchSpaceId, + start_date: startDateStr, + end_date: endDateStr, + }, + }); + } + + toast.success(`${indexingConfig.connectorTitle} indexing started`, { + description: periodicEnabled + ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` + : "You can continue working while we sync your data.", + }); + + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error starting indexing:", error); + toast.error("Failed to start indexing"); + } finally { + setIsStartingIndexing(false); + } + }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, indexingConnectorConfig]); + + // Handle skipping indexing + const handleSkipIndexing = useCallback(() => { + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); + + // Handle starting edit mode + const handleStartEdit = useCallback((connector: SearchSourceConnector) => { + if (!searchSpaceId) return; + + // Check if this is an OAuth connector + const isOAuthConnector = OAUTH_CONNECTORS.some( + (oauthConnector) => oauthConnector.connectorType === connector.connector_type + ); + + // If not OAuth, redirect to old connector edit page + if (!isOAuthConnector) { + router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`); + return; + } + + // Validate connector data + const connectorValidation = searchSourceConnector.safeParse(connector); + if (!connectorValidation.success) { + toast.error("Invalid connector data"); + return; + } + + setEditingConnector(connector); + // Load existing periodic sync settings + setPeriodicEnabled(connector.periodic_indexing_enabled); + setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440"); + // Reset dates - user can set new ones for re-indexing + setStartDate(undefined); + setEndDate(undefined); + + // Update URL + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("view", "edit"); + url.searchParams.set("connectorId", connector.id.toString()); + window.history.pushState({ modal: true }, "", url.toString()); + }, [searchSpaceId, router]); + + // Handle saving connector changes + const handleSaveConnector = useCallback(async (refreshConnectors: () => void) => { + if (!editingConnector || !searchSpaceId) return; + + // Validate date range (skip for Google Drive which uses folder selection) + if (editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR") { + const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); + if (!dateRangeValidation.success) { + toast.error(dateRangeValidation.error.issues[0]?.message || "Invalid date range"); + return; + } + } + + // Validate frequency minutes if periodic is enabled + if (periodicEnabled) { + const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); + if (!frequencyValidation.success) { + toast.error("Invalid frequency value"); + return; + } + } + + setIsSaving(true); + try { + const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; + const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; + + // Update connector with periodic sync settings and config changes + const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : null; + await updateConnector({ + id: editingConnector.id, + data: { + periodic_indexing_enabled: periodicEnabled, + indexing_frequency_minutes: frequency, + config: connectorConfig || editingConnector.config, + }, + }); + + // Re-index based on connector type + let indexingDescription = "Settings saved."; + if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") { + // Google Drive uses folder selection from config, not date ranges + const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as Array<{ id: string; name: string }> | undefined; + if (selectedFolders && selectedFolders.length > 0) { + const folderIds = selectedFolders.map((f) => f.id).join(","); + const folderNames = selectedFolders.map((f) => f.name).join(", "); + await indexConnector({ + connector_id: editingConnector.id, + queryParams: { + search_space_id: searchSpaceId, + folder_ids: folderIds, + folder_names: folderNames, + }, + }); + indexingDescription = `Re-indexing started for ${selectedFolders.length} folder(s).`; + } + } else if (startDateStr || endDateStr) { + // Other connectors use date ranges + await indexConnector({ + connector_id: editingConnector.id, + queryParams: { + search_space_id: searchSpaceId, + start_date: startDateStr, + end_date: endDateStr, + }, + }); + indexingDescription = "Re-indexing started with new date range."; + } + + toast.success(`${editingConnector.name} updated successfully`, { + description: periodicEnabled + ? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}` + : indexingDescription, + }); + + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error saving connector:", error); + toast.error("Failed to save connector changes"); + } finally { + setIsSaving(false); + } + }, [editingConnector, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, connectorConfig]); + + // Handle disconnecting connector + const handleDisconnectConnector = useCallback(async (refreshConnectors: () => void) => { + if (!editingConnector || !searchSpaceId) return; + + setIsDisconnecting(true); + try { + await deleteConnector({ + id: editingConnector.id, + }); + + toast.success(`${editingConnector.name} disconnected successfully`); + + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error disconnecting connector:", error); + toast.error("Failed to disconnect connector"); + } finally { + setIsDisconnecting(false); + } + }, [editingConnector, searchSpaceId, deleteConnector, router]); + + // Handle going back from edit view + const handleBackFromEdit = useCallback(() => { + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", "all"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); + + // Handle dialog open/close + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + + if (open) { + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", activeTab); + window.history.pushState({ modal: true }, "", url.toString()); + } else { + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + window.history.pushState({ modal: false }, "", url.toString()); + setIsScrolled(false); + setSearchQuery(""); + if (!isStartingIndexing && !isSaving && !isDisconnecting) { + setIndexingConfig(null); + setIndexingConnector(null); + setIndexingConnectorConfig(null); + setEditingConnector(null); + setConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + } + } + }, + [activeTab, isStartingIndexing, isDisconnecting, isSaving] + ); + + // Handle tab change + 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 + const handleScroll = useCallback((e: React.UIEvent) => { + setIsScrolled(e.currentTarget.scrollTop > 0); + }, []); + + return { + // State + isOpen, + activeTab, + connectingId, + isScrolled, + searchQuery, + indexingConfig, + indexingConnector, + indexingConnectorConfig, + editingConnector, + startDate, + endDate, + isStartingIndexing, + isSaving, + isDisconnecting, + periodicEnabled, + frequencyMinutes, + searchSpaceId, + allConnectors, + + // Setters + setSearchQuery, + setStartDate, + setEndDate, + setPeriodicEnabled, + setFrequencyMinutes, + + // Handlers + handleOpenChange, + handleTabChange, + handleScroll, + handleConnectOAuth, + handleStartIndexing, + handleSkipIndexing, + handleStartEdit, + handleSaveConnector, + handleDisconnectConnector, + handleBackFromEdit, + connectorConfig, + setConnectorConfig, + setIndexingConnectorConfig, + }; +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/index.ts b/surfsense_web/components/assistant-ui/connector-popup/index.ts index 1aea4de95..1c5ebc471 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/index.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/index.ts @@ -2,17 +2,18 @@ export { ConnectorIndicator } from "../connector-popup"; // Sub-components (if needed for external use) -export { ConnectorCard } from "./connector-card"; -export { DateRangeSelector } from "./date-range-selector"; -export { PeriodicSyncConfig } from "./periodic-sync-config"; -export { IndexingConfigurationView } from "./indexing-configuration-view"; -export { ConnectorDialogHeader } from "./connector-dialog-header"; -export { AllConnectorsTab } from "./all-connectors-tab"; -export { ActiveConnectorsTab } from "./active-connectors-tab"; +export { ConnectorCard } from "./components/connector-card"; +export { DateRangeSelector } from "./components/date-range-selector"; +export { PeriodicSyncConfig } from "./components/periodic-sync-config"; +export { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view"; +export { ConnectorEditView } from "./connector-configs/views/connector-edit-view"; +export { ConnectorDialogHeader } from "./components/connector-dialog-header"; +export { AllConnectorsTab } from "./tabs/all-connectors-tab"; +export { ActiveConnectorsTab } from "./tabs/active-connectors-tab"; // Constants and types -export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; -export type { IndexingConfigState } from "./connector-constants"; +export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./constants/connector-constants"; +export type { IndexingConfigState } from "./constants/connector-constants"; // Schemas and validation export { @@ -24,14 +25,14 @@ export { parseConnectorPopupQueryParams, parseOAuthAuthResponse, validateIndexingConfigState, -} from "./connector-popup.schemas"; +} from "./constants/connector-popup.schemas"; export type { ConnectorPopupQueryParams, OAuthAuthResponse, FrequencyMinutes, DateRange, -} from "./connector-popup.schemas"; +} from "./constants/connector-popup.schemas"; // Hooks -export { useConnectorDialog } from "./use-connector-dialog"; +export { useConnectorDialog } from "./hooks/use-connector-dialog"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx similarity index 97% rename from surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx rename to surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index 43deba278..fd364c3d1 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -24,6 +24,7 @@ interface ActiveConnectorsTabProps { logsSummary: LogSummary | undefined; searchSpaceId: string; onTabChange: (value: string) => void; + onManage?: (connector: SearchSourceConnector) => void; } export const ActiveConnectorsTab: FC = ({ @@ -34,6 +35,7 @@ export const ActiveConnectorsTab: FC = ({ logsSummary, searchSpaceId, onTabChange, + onManage, }) => { const router = useRouter(); @@ -119,11 +121,7 @@ export const ActiveConnectorsTab: FC = ({ variant="outline" size="sm" className="h-8 text-[11px] px-3 rounded-lg font-medium" - onClick={() => - router.push( - `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` - ) - } + onClick={onManage ? () => onManage(connector) : undefined} disabled={isIndexing} > {isIndexing ? "Syncing..." : "Manage"} diff --git a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx similarity index 72% rename from surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx rename to surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 4dd056c90..b06c5f274 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -2,15 +2,18 @@ import { useRouter } from "next/navigation"; import { type FC } from "react"; -import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; -import { ConnectorCard } from "./connector-card"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants"; +import { ConnectorCard } from "../components/connector-card"; interface AllConnectorsTabProps { searchQuery: string; searchSpaceId: string; connectedTypes: Set; connectingId: string | null; + allConnectors: SearchSourceConnector[] | undefined; onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[0]) => void; + onManage?: (connector: SearchSourceConnector) => void; } export const AllConnectorsTab: FC = ({ @@ -18,7 +21,9 @@ export const AllConnectorsTab: FC = ({ searchSpaceId, connectedTypes, connectingId, + allConnectors, onConnectOAuth, + onManage, }) => { const router = useRouter(); @@ -49,6 +54,10 @@ export const AllConnectorsTab: FC = ({ {filteredOAuth.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); const isConnecting = connectingId === connector.id; + // Find the actual connector object if connected + const actualConnector = isConnected && allConnectors + ? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType) + : undefined; return ( = ({ isConnected={isConnected} isConnecting={isConnecting} onConnect={() => onConnectOAuth(connector)} - onManage={() => - router.push( - `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` - ) - } + onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined} /> ); })} @@ -83,6 +88,10 @@ export const AllConnectorsTab: FC = ({
{filteredOther.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); + // Find the actual connector object if connected + const actualConnector = isConnected && allConnectors + ? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType) + : undefined; return ( = ({ `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` ) } - onManage={() => - router.push( - `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` - ) - } + onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined} /> ); })} diff --git a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts deleted file mode 100644 index 32a475300..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { useAtomValue } from "jotai"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms"; -import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; -import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { authenticatedFetch } from "@/lib/auth-utils"; -import { queryClient } from "@/lib/query-client/client"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { format } from "date-fns"; -import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { searchSourceConnector } from "@/contracts/types/connector.types"; -import { OAUTH_CONNECTORS } from "./connector-constants"; -import type { IndexingConfigState } from "./connector-constants"; -import { - parseConnectorPopupQueryParams, - parseOAuthAuthResponse, - validateIndexingConfigState, - frequencyMinutesSchema, - dateRangeSchema, -} from "./connector-popup.schemas"; - -export const useConnectorDialog = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); - const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); - const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); - - const [isOpen, setIsOpen] = useState(false); - const [activeTab, setActiveTab] = useState("all"); - const [connectingId, setConnectingId] = useState(null); - const [isScrolled, setIsScrolled] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [indexingConfig, setIndexingConfig] = useState(null); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [isStartingIndexing, setIsStartingIndexing] = useState(false); - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); - - // Helper function to get frequency label - const getFrequencyLabel = useCallback((minutes: string): string => { - switch (minutes) { - case "15": return "15 minutes"; - case "60": return "hour"; - case "360": return "6 hours"; - case "720": return "12 hours"; - case "1440": return "day"; - case "10080": return "week"; - default: return `${minutes} minutes`; - } - }, []); - - // Synchronize state with URL query params - useEffect(() => { - try { - const params = parseConnectorPopupQueryParams(searchParams); - - if (params.modal === "connectors") { - setIsOpen(true); - - if (params.tab === "active" || params.tab === "all") { - setActiveTab(params.tab); - } - - // Clear indexing config if view is not "configure" anymore - if (params.view !== "configure" && indexingConfig) { - setIndexingConfig(null); - } - - if (params.view === "configure" && params.connector && !indexingConfig) { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); - if (oauthConnector && allConnectors) { - const existingConnector = allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (existingConnector) { - // Validate connector data before setting state - const connectorValidation = searchSourceConnector.safeParse(existingConnector); - if (connectorValidation.success) { - const config = validateIndexingConfigState({ - connectorType: oauthConnector.connectorType, - connectorId: existingConnector.id, - connectorTitle: oauthConnector.title, - }); - setIndexingConfig(config); - } - } - } - } - } else { - setIsOpen(false); - // Clear indexing config when modal is closed - if (indexingConfig) { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - setIsScrolled(false); - setSearchQuery(""); - } - } - } catch (error) { - // Invalid query params - log but don't crash - console.warn("Invalid connector popup query params:", error); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams, allConnectors]); - - // Detect OAuth success and transition to config view - useEffect(() => { - try { - const params = parseConnectorPopupQueryParams(searchParams); - - if (params.success === "true" && params.connector && searchSpaceId && params.modal === "connectors") { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); - if (oauthConnector) { - refetchAllConnectors().then((result) => { - if (!result.data) return; - - const newConnector = result.data.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (newConnector) { - // Validate connector data before setting state - const connectorValidation = searchSourceConnector.safeParse(newConnector); - if (connectorValidation.success) { - const config = validateIndexingConfigState({ - connectorType: oauthConnector.connectorType, - connectorId: newConnector.id, - connectorTitle: oauthConnector.title, - }); - setIndexingConfig(config); - setIsOpen(true); - const url = new URL(window.location.href); - url.searchParams.delete("success"); - url.searchParams.set("view", "configure"); - window.history.replaceState({}, "", url.toString()); - } else { - console.warn("Invalid connector data after OAuth:", connectorValidation.error); - toast.error("Failed to validate connector data"); - } - } - }); - } - } - } catch (error) { - // Invalid query params - log but don't crash - console.warn("Invalid connector popup query params in OAuth success handler:", error); - } - }, [searchParams, searchSpaceId, refetchAllConnectors]); - - // Handle OAuth connection - const handleConnectOAuth = useCallback( - async (connector: (typeof OAUTH_CONNECTORS)[0]) => { - if (!searchSpaceId || !connector.authEndpoint) return; - - // Set connecting state immediately to disable button and show spinner - setConnectingId(connector.id); - - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, - { method: "GET" } - ); - - if (!response.ok) { - throw new Error(`Failed to initiate ${connector.title} OAuth`); - } - - const data = await response.json(); - - // Validate OAuth response with Zod - const validatedData = parseOAuthAuthResponse(data); - - // Don't clear connectingId here - let the redirect happen with button still disabled - // The component will unmount on redirect anyway - window.location.href = validatedData.auth_url; - } catch (error) { - console.error(`Error connecting to ${connector.title}:`, error); - if (error instanceof Error && error.message.includes("Invalid auth URL")) { - toast.error(`Invalid response from ${connector.title} OAuth endpoint`); - } else { - toast.error(`Failed to connect to ${connector.title}`); - } - // Only clear connectingId on error so user can retry - setConnectingId(null); - } - }, - [searchSpaceId] - ); - - // Handle starting indexing - const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => { - if (!indexingConfig || !searchSpaceId) return; - - // Validate date range - const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); - if (!dateRangeValidation.success) { - toast.error(dateRangeValidation.error.errors[0]?.message || "Invalid date range"); - return; - } - - // Validate frequency minutes if periodic is enabled - if (periodicEnabled) { - const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); - if (!frequencyValidation.success) { - toast.error("Invalid frequency value"); - return; - } - } - - setIsStartingIndexing(true); - try { - const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; - const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; - - if (periodicEnabled) { - const frequency = parseInt(frequencyMinutes, 10); - await updateConnector({ - id: indexingConfig.connectorId, - data: { - periodic_indexing_enabled: true, - indexing_frequency_minutes: frequency, - }, - }); - } - - await indexConnector({ - connector_id: indexingConfig.connectorId, - queryParams: { - search_space_id: searchSpaceId, - start_date: startDateStr, - end_date: endDateStr, - }, - }); - - toast.success(`${indexingConfig.connectorTitle} indexing started`, { - description: periodicEnabled - ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` - : "You can continue working while we sync your data.", - }); - - // Update URL - the effect will handle closing the modal and clearing state - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - router.replace(url.pathname + url.search, { scroll: false }); - - refreshConnectors(); - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - } catch (error) { - console.error("Error starting indexing:", error); - toast.error("Failed to start indexing"); - } finally { - setIsStartingIndexing(false); - } - }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router]); - - // Handle skipping indexing - const handleSkipIndexing = useCallback(() => { - // Update URL - the effect will handle closing the modal and clearing state - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - router.replace(url.pathname + url.search, { scroll: false }); - }, [router]); - - // Handle dialog open/close - const handleOpenChange = useCallback( - (open: boolean) => { - setIsOpen(open); - - if (open) { - const url = new URL(window.location.href); - url.searchParams.set("modal", "connectors"); - url.searchParams.set("tab", activeTab); - window.history.pushState({ modal: true }, "", url.toString()); - } else { - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - window.history.pushState({ modal: false }, "", url.toString()); - setIsScrolled(false); - setSearchQuery(""); - if (!isStartingIndexing) { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - } - } - }, - [activeTab, isStartingIndexing] - ); - - // Handle tab change - 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 - const handleScroll = useCallback((e: React.UIEvent) => { - setIsScrolled(e.currentTarget.scrollTop > 0); - }, []); - - return { - // State - isOpen, - activeTab, - connectingId, - isScrolled, - searchQuery, - indexingConfig, - startDate, - endDate, - isStartingIndexing, - periodicEnabled, - frequencyMinutes, - searchSpaceId, - allConnectors, - - // Setters - setSearchQuery, - setStartDate, - setEndDate, - setPeriodicEnabled, - setFrequencyMinutes, - - // Handlers - handleOpenChange, - handleTabChange, - handleScroll, - handleConnectOAuth, - handleStartIndexing, - handleSkipIndexing, - }; -}; - diff --git a/surfsense_web/components/connectors/google-drive-folder-tree.tsx b/surfsense_web/components/connectors/google-drive-folder-tree.tsx index cec207b2a..eed18f173 100644 --- a/surfsense_web/components/connectors/google-drive-folder-tree.tsx +++ b/surfsense_web/components/connectors/google-drive-folder-tree.tsx @@ -14,7 +14,6 @@ import { Presentation, } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; @@ -207,63 +206,74 @@ export function GoogleDriveFolderTree({ const childFolders = children?.filter((c) => c.isFolder) || []; const childFiles = children?.filter((c) => !c.isFolder) || []; + const indentSize = 0.75; // Smaller indent for mobile + return ( -
+
{isFolder ? ( - { e.stopPropagation(); toggleFolder(item); }} + aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`} > {isLoading ? ( - + ) : isExpanded ? ( - + ) : ( - + )} - + ) : ( - + )} {isFolder && ( toggleFolderSelection(item.id, item.name)} - className="shrink-0" + className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4" onClick={(e) => e.stopPropagation()} /> )} -
+
{isFolder ? ( isExpanded ? ( - + ) : ( - + ) ) : ( - getFileIcon(item.mimeType, "h-4 w-4") + getFileIcon(item.mimeType, "h-3 w-3 sm:h-4 sm:w-4") )}
- isFolder && toggleFolder(item)} - > - {item.name} - + {isFolder ? ( + + ) : ( + + {item.name} + + )}
{isExpanded && isFolder && children && ( @@ -272,7 +282,7 @@ export function GoogleDriveFolderTree({ {childFiles.map((child) => renderItem(child, level + 1))} {children.length === 0 && ( -
Empty folder
+
Empty folder
)}
)} @@ -282,25 +292,29 @@ export function GoogleDriveFolderTree({ return (
- -
-
-
+ +
+
+
toggleFolderSelection("root", "My Drive")} - className="shrink-0" + className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4" /> - - toggleFolderSelection("root", "My Drive")}> + +
{isLoadingRoot && ( -
- +
+
)} @@ -309,7 +323,7 @@ export function GoogleDriveFolderTree({
{!isLoadingRoot && rootItems.length === 0 && ( -
+
No files or folders found in your Google Drive
)} diff --git a/surfsense_web/components/ui/switch.tsx b/surfsense_web/components/ui/switch.tsx index b64b32b73..de2c35fc0 100644 --- a/surfsense_web/components/ui/switch.tsx +++ b/surfsense_web/components/ui/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( From 5d1859db1748b80a7beb0320b8d6eb83e9dc16c9 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:41:57 +0530 Subject: [PATCH 10/28] feat: Add Webcrawler and YouTube connector configurations, enhance connector dialog with creation functionality, and improve UI responsiveness and styling across components. --- .../assistant-ui/connector-popup.tsx | 4 + .../components/date-range-selector.tsx | 20 +-- .../components/periodic-sync-config.tsx | 22 +-- .../components/google-drive-config.tsx | 4 +- .../components/webcrawler-config.tsx | 127 ++++++++++++++ .../components/youtube-config.tsx | 148 ++++++++++++++++ .../connector-configs/index.tsx | 6 + .../views/connector-edit-view.tsx | 27 +-- .../views/indexing-configuration-view.tsx | 24 +-- .../constants/connector-constants.ts | 6 + .../hooks/use-connector-dialog.ts | 166 ++++++++++++++++-- .../tabs/all-connectors-tab.tsx | 21 ++- surfsense_web/contracts/enums/connector.ts | 1 + .../contracts/enums/connectorIcons.tsx | 2 + .../contracts/types/connector.types.ts | 1 + 15 files changed, 512 insertions(+), 67 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/youtube-config.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index c1ed38e7b..55a11f420 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -88,6 +88,8 @@ export const ConnectorIndicator: FC = () => { handleTabChange, handleScroll, handleConnectOAuth, + handleCreateWebcrawler, + handleCreateYouTube, handleStartIndexing, handleSkipIndexing, handleStartEdit, @@ -212,6 +214,8 @@ export const ConnectorIndicator: FC = () => { connectingId={connectingId} allConnectors={allConnectors} onConnectOAuth={handleConnectOAuth} + onCreateWebcrawler={handleCreateWebcrawler} + onCreateYouTube={handleCreateYouTube} onManage={handleStartEdit} /> diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx index 1112f3f36..5c7870639 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx @@ -40,23 +40,23 @@ export const DateRangeSelector: FC = ({ }; return ( -
-

Select Date Range

-

+

+

Select Date Range

+

Choose how far back you want to sync your data. You can always re-index later with different dates.

{/* Start Date */}
- + @@ -120,7 +120,7 @@ export const DateRangeSelector: FC = ({ variant="outline" size="sm" onClick={handleLast30Days} - className="text-xs bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10" + className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10" > Last 30 Days @@ -129,7 +129,7 @@ export const DateRangeSelector: FC = ({ variant="outline" size="sm" onClick={handleLastYear} - className="text-xs bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10" + className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10" > Last Year diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx index 427a6ac86..f8b869a67 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx @@ -25,11 +25,11 @@ export const PeriodicSyncConfig: FC = ({ onFrequencyChange, }) => { return ( -
+
-

Enable Periodic Sync

-

+

Enable Periodic Sync

+

Automatically re-index at regular intervals

@@ -39,21 +39,21 @@ export const PeriodicSyncConfig: FC = ({ {enabled && (
- +
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx index 280d6ed23..a30450dad 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx @@ -91,9 +91,9 @@ export const GoogleDriveConfig: FC = ({ )} - + - + Folder selection is used when indexing. You can change this selection when you start indexing. diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx new file mode 100644 index 000000000..4bb75c58d --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { Info } from "lucide-react"; +import { useState, useEffect } from "react"; +import type { FC } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import type { ConnectorConfigProps } from "../index"; + +export const WebcrawlerConfig: FC = ({ + connector, + onConfigChange, +}) => { + // Initialize with existing config values + const existingApiKey = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || ""; + const existingUrls = (connector.config?.INITIAL_URLS as string | undefined) || ""; + + const [apiKey, setApiKey] = useState(existingApiKey); + const [initialUrls, setInitialUrls] = useState(existingUrls); + const [showApiKey, setShowApiKey] = useState(false); + + // Update state when connector config changes + useEffect(() => { + const apiKeyValue = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || ""; + const urlsValue = (connector.config?.INITIAL_URLS as string | undefined) || ""; + setApiKey(apiKeyValue); + setInitialUrls(urlsValue); + }, [connector.config]); + + const handleApiKeyChange = (value: string) => { + setApiKey(value); + if (onConfigChange) { + onConfigChange({ + ...connector.config, + FIRECRAWL_API_KEY: value.trim() || undefined, + }); + } + }; + + const handleUrlsChange = (value: string) => { + setInitialUrls(value); + if (onConfigChange) { + onConfigChange({ + ...connector.config, + INITIAL_URLS: value.trim() || undefined, + }); + } + }; + + return ( +
+
+

Web Crawler Configuration

+

+ Configure your web crawler settings. You can add a Firecrawl API key for enhanced crawling or use the free fallback option. +

+
+ + {/* API Key Field */} +
+ +
+ handleApiKeyChange(e.target.value)} + className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 text-xs sm:text-sm pr-10" + /> + +
+

+ Get your API key from{" "} + + firecrawl.dev + + . If not provided, will use AsyncChromiumLoader as fallback. +

+
+ + {/* Initial URLs Field */} +
+ +