feat: Add Tavily API connector support, including configuration forms and connection handling, enhance connector dialog with non-OAuth connection logic, and improve state management for connector creation.

This commit is contained in:
Anish Sarkar 2025-12-31 12:35:43 +05:30
parent 5d1859db17
commit 880b3cc4bf
11 changed files with 711 additions and 86 deletions

View file

@ -2,11 +2,13 @@
import { useAtomValue } from "jotai";
import { Cable, Loader2 } from "lucide-react";
import { type FC, useMemo } from "react";
import { type FC, useMemo, useEffect } 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import {
Dialog,
DialogContent,
@ -21,16 +23,13 @@ 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 { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-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 = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { connectors, isLoading: connectorsLoading, refreshConnectors } = useSearchSourceConnectors(
false,
searchSpaceId ? Number(searchSpaceId) : undefined
);
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
@ -44,22 +43,6 @@ export const ConnectorIndicator: FC = () => {
}
);
// Get connector IDs that are currently being indexed
const indexingConnectorIds = useMemo(() => {
if (!logsSummary?.active_tasks) return new Set<number>();
return new Set(
logsSummary.active_tasks
.filter((task) => task.source?.includes("connector_indexing"))
.map((task) => {
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;
// Use the custom hook for dialog state management
const {
isOpen,
@ -71,6 +54,8 @@ export const ConnectorIndicator: FC = () => {
indexingConnector,
indexingConnectorConfig,
editingConnector,
connectingConnectorType,
isCreatingConnector,
startDate,
endDate,
isStartingIndexing,
@ -88,19 +73,81 @@ export const ConnectorIndicator: FC = () => {
handleTabChange,
handleScroll,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTube,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
} = useConnectorDialog();
// Fetch connectors using React Query with conditional refetchInterval
// This automatically refetches when mutations invalidate the cache (event-driven)
// and also polls when dialog is open to catch external changes
const {
data: connectors = [],
isLoading: connectorsLoading,
refetch: refreshConnectors,
} = useQuery({
queryKey: cacheKeys.connectors.all(searchSpaceId || ""),
queryFn: () =>
connectorsApiService.getConnectors({
queryParams: {
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
},
}),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes (same as connectorsAtom)
// Poll when dialog is open to catch external changes
refetchInterval: isOpen ? 5000 : false, // 5 seconds when open, no polling when closed
});
const queryClient = useQueryClient();
// Also refresh document type counts when dialog is open
useEffect(() => {
if (!isOpen || !searchSpaceId) return;
const POLL_INTERVAL = 5000; // 5 seconds, same as connectors
const intervalId = setInterval(() => {
// Invalidate document type counts to refresh active document types
queryClient.invalidateQueries({
queryKey: cacheKeys.documents.typeCounts(searchSpaceId),
});
}, POLL_INTERVAL);
// Cleanup interval on unmount or when dialog closes
return () => {
clearInterval(intervalId);
};
}, [isOpen, searchSpaceId, queryClient]);
// Get connector IDs that are currently being indexed
const indexingConnectorIds = useMemo(() => {
if (!logsSummary?.active_tasks) return new Set<number>();
return new Set(
logsSummary.active_tasks
.filter((task) => task.source?.includes("connector_indexing"))
.map((task) => {
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;
// Get document types that have documents in the search space
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
@ -148,12 +195,20 @@ export const ConnectorIndicator: FC = () => {
</TooltipIconButton>
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[90vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-6 sm:[&>button]:right-12 [&>button]:top-8 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
{/* Connector Edit View - shown when editing existing connector */}
{editingConnector ? (
{/* Connector Connect View - shown when connecting non-OAuth connectors */}
{connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={handleSubmitConnectForm}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
) : editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
}}
startDate={startDate}
endDate={endDate}
@ -165,10 +220,11 @@ export const ConnectorIndicator: FC = () => {
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onSave={() => handleSaveConnector(refreshConnectors)}
onDisconnect={() => handleDisconnectConnector(refreshConnectors)}
onSave={() => handleSaveConnector(() => refreshConnectors())}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
) : indexingConfig ? (
<IndexingConfigurationView
@ -187,7 +243,7 @@ export const ConnectorIndicator: FC = () => {
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => handleStartIndexing(refreshConnectors)}
onStartIndexing={() => handleStartIndexing(() => refreshConnectors())}
onSkip={handleSkipIndexing}
/>
) : (
@ -207,17 +263,18 @@ export const ConnectorIndicator: FC = () => {
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-6 sm:px-12 py-6 sm:py-8 pb-16 sm:pb-16">
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={allConnectors}
onConnectOAuth={handleConnectOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTube={handleCreateYouTube}
onManage={handleStartEdit}
/>
<AllConnectorsTab
searchQuery={searchQuery}
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={allConnectors}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTube={handleCreateYouTube}
onManage={handleStartEdit}
/>
</TabsContent>
<ActiveConnectorsTab

View file

@ -0,0 +1,92 @@
"use client";
import { KeyRound } from "lucide-react";
import { useState, useEffect } from "react";
import type { FC } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
export interface TavilyApiConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const TavilyApiConfig: FC<TavilyApiConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [apiKey, setApiKey] = useState<string>(
(connector.config?.TAVILY_API_KEY as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update API key and name when connector changes
useEffect(() => {
const key = (connector.config?.TAVILY_API_KEY as string) || "";
setApiKey(key);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
TAVILY_API_KEY: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Tavily API Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
Tavily API Key
</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="Enter your Tavily API key"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update the Tavily API Key if needed.
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,36 @@
import type { FC } from "react";
import { TavilyApiConnectForm } from "./tavily-api-connect-form";
export interface ConnectFormProps {
onSubmit: (data: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: null;
next_scheduled_at: null;
}) => Promise<void>;
onBack: () => void;
isSubmitting: boolean;
onFormSubmit?: () => void;
}
export type ConnectFormComponent = FC<ConnectFormProps>;
/**
* Factory function to get the appropriate connect form component for a connector type
*/
export function getConnectFormComponent(
connectorType: string
): ConnectFormComponent | null {
switch (connectorType) {
case "TAVILY_API":
return TavilyApiConnectForm;
// Add other connector types here as needed
default:
return null;
}
}

View file

@ -0,0 +1,145 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { ConnectFormProps } from "./index";
const tavilyApiFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
export const TavilyApiConnectForm: FC<ConnectFormProps> = ({
onSubmit,
isSubmitting,
}) => {
const isSubmittingRef = useRef(false);
const form = useForm<TavilyApiFormValues>({
resolver: zodResolver(tavilyApiFormSchema),
defaultValues: {
name: "Tavily API Connector",
api_key: "",
},
});
const handleSubmit = async (values: TavilyApiFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.TAVILY_API,
config: {
TAVILY_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
} finally {
isSubmittingRef.current = false;
}
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
<a
href="https://tavily.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
tavily.com
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form id="tavily-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Tavily API Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Tavily API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Tavily API key"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
</div>
);
};

View file

@ -3,12 +3,14 @@
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { GoogleDriveConfig } from "./components/google-drive-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
import { WebcrawlerConfig } from "./components/webcrawler-config";
import { YouTubeConfig } from "./components/youtube-config";
export interface ConnectorConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
@ -22,6 +24,8 @@ export function getConnectorConfigComponent(
switch (connectorType) {
case "GOOGLE_DRIVE_CONNECTOR":
return GoogleDriveConfig;
case "TAVILY_API":
return TavilyApiConfig;
case "WEBCRAWLER_CONNECTOR":
return WebcrawlerConfig;
case "YOUTUBE_CONNECTOR":

View file

@ -0,0 +1,131 @@
"use client";
import { ArrowLeft, Loader2 } from "lucide-react";
import { type FC, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { getConnectFormComponent } from "../connect-forms";
interface ConnectorConnectViewProps {
connectorType: string;
onSubmit: (data: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: null;
next_scheduled_at: null;
}) => Promise<void>;
onBack: () => void;
isSubmitting: boolean;
}
export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
connectorType,
onSubmit,
onBack,
isSubmitting,
}) => {
// Get connector-specific form component
const ConnectFormComponent = useMemo(
() => getConnectFormComponent(connectorType),
[connectorType]
);
const handleFormSubmit = () => {
// Prevent multiple submissions
if (isSubmitting) {
return;
}
const form = document.getElementById("tavily-connect-form") as HTMLFormElement;
if (form) {
form.requestSubmit();
}
};
if (!ConnectFormComponent) {
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-6">
<p className="text-sm text-muted-foreground mb-4">
Connector form not found for type: {connectorType}
</p>
<Button onClick={onBack} variant="ghost">
Back
</Button>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<button
type="button"
onClick={onBack}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
>
<ArrowLeft className="size-4" />
Back to connectors
</button>
<div className="flex items-center gap-4 mb-6">
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-slate-400/30">
{getConnectorIcon(connectorType as EnumConnectorName, "h-7 w-7")}
</div>
<div>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
Connect {connectorType === "TAVILY_API" ? "Tavily API" : connectorType}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Enter your connection details
</p>
</div>
</div>
</div>
{/* Form Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
<ConnectFormComponent
onSubmit={onSubmit}
onBack={onBack}
isSubmitting={isSubmitting}
onFormSubmit={handleFormSubmit}
/>
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
<Button
variant="ghost"
onClick={onBack}
disabled={isSubmitting}
className="text-xs sm:text-sm"
>
Cancel
</Button>
<Button
onClick={handleFormSubmit}
disabled={isSubmitting}
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
Connect {getConnectorTypeDisplay(connectorType)}
</>
)}
</Button>
</div>
</div>
);
};

View file

@ -26,6 +26,7 @@ interface ConnectorEditViewProps {
onDisconnect: () => void;
onBack: () => void;
onConfigChange?: (config: Record<string, any>) => void;
onNameChange?: (name: string) => void;
}
export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
@ -44,6 +45,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
onDisconnect,
onBack,
onConfigChange,
onNameChange,
}) => {
// Get connector-specific config component
const ConnectorConfigComponent = useMemo(
@ -146,25 +148,31 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<ConnectorConfigComponent
connector={connector}
onConfigChange={onConfigChange}
onNameChange={onNameChange}
/>
)}
{/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && connector.connector_type !== "WEBCRAWLER_CONNECTOR" && connector.connector_type !== "YOUTUBE_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
)}
{/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector.is_indexable && (
<>
{/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && connector.connector_type !== "WEBCRAWLER_CONNECTOR" && connector.connector_type !== "YOUTUBE_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
)}
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
</>
)}
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">

View file

@ -3,7 +3,6 @@
import { ArrowLeft, Check, Info, Loader2 } 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 type { IndexingConfigState } from "../../constants/connector-constants";
@ -134,22 +133,27 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
/>
)}
{/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && config.connectorType !== "WEBCRAWLER_CONNECTOR" && config.connectorType !== "YOUTUBE_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
)}
{/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector?.is_indexable && (
<>
{/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && config.connectorType !== "WEBCRAWLER_CONNECTOR" && config.connectorType !== "YOUTUBE_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
)}
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
</>
)}
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">

View file

@ -7,9 +7,10 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
view: z.enum(["configure", "edit"]).optional(),
view: z.enum(["configure", "edit", "connect"]).optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),
success: z.enum(["true", "false"]).optional(),
});

View file

@ -1,6 +1,6 @@
import { useAtomValue } from "jotai";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { createConnectorMutationAtom, deleteConnectorMutationAtom, indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
@ -51,6 +51,12 @@ export const useConnectorDialog = () => {
const [isSaving, setIsSaving] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [connectorConfig, setConnectorConfig] = useState<Record<string, unknown> | null>(null);
const [connectorName, setConnectorName] = useState<string | null>(null);
// Connect mode state (for non-OAuth connectors)
const [connectingConnectorType, setConnectingConnectorType] = useState<string | null>(null);
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const isCreatingConnectorRef = useRef(false);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
@ -85,6 +91,17 @@ export const useConnectorDialog = () => {
// Clear editing connector if view is not "edit" anymore
if (params.view !== "edit" && editingConnector) {
setEditingConnector(null);
setConnectorName(null);
}
// Clear connecting connector type if view is not "connect" anymore
if (params.view !== "connect" && connectingConnectorType) {
setConnectingConnectorType(null);
}
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
}
if (params.view === "configure" && params.connector && !indexingConfig) {
@ -147,6 +164,7 @@ export const useConnectorDialog = () => {
// Clear editing connector when modal is closed
if (editingConnector) {
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
setStartDate(undefined);
setEndDate(undefined);
@ -155,13 +173,17 @@ export const useConnectorDialog = () => {
setIsScrolled(false);
setSearchQuery("");
}
// Clear connecting connector type when modal is closed
if (connectingConnectorType) {
setConnectingConnectorType(null);
}
}
} 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]);
}, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType]);
// Detect OAuth success and transition to config view
useEffect(() => {
@ -358,6 +380,106 @@ export const useConnectorDialog = () => {
}
}, [searchSpaceId, createConnector, refetchAllConnectors]);
// Handle connecting non-OAuth connectors (like Tavily API)
const handleConnectNonOAuth = useCallback((connectorType: string) => {
if (!searchSpaceId) return;
// Set connecting state
setConnectingConnectorType(connectorType);
// Update URL to show connect view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "connect");
url.searchParams.set("connectorType", connectorType);
window.history.pushState({ modal: true }, "", url.toString());
}, [searchSpaceId]);
// Handle submitting connect form
const handleSubmitConnectForm = useCallback(async (
formData: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: null;
next_scheduled_at: null;
}
) => {
if (!searchSpaceId || !connectingConnectorType) return;
// Prevent multiple submissions using ref for immediate check
if (isCreatingConnectorRef.current) return;
isCreatingConnectorRef.current = true;
setIsCreatingConnector(true);
try {
// Create connector
const newConnector = await createConnector({
data: formData,
queryParams: {
search_space_id: searchSpaceId,
},
});
// Refetch connectors to get the new one
const result = await refetchAllConnectors();
if (result.data) {
const connector = result.data.find(
(c: SearchSourceConnector) => c.id === newConnector.id
);
if (connector) {
// Validate connector data
const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) {
// Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find(
c => c.connectorType === connectingConnectorType
);
const connectorTitle = connectorInfo?.title || connector.name;
// Set up indexing config
const config = validateIndexingConfigState({
connectorType: connectingConnectorType as EnumConnectorName,
connectorId: connector.id,
connectorTitle,
});
setIndexingConfig(config);
setIndexingConnector(connector);
setIndexingConnectorConfig(connector.config || {});
// Transition to configure view
const url = new URL(window.location.href);
url.searchParams.set("view", "configure");
url.searchParams.delete("connectorType");
window.history.replaceState({}, "", url.toString());
toast.success(`${connectorTitle} connected successfully!`);
}
}
}
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
isCreatingConnectorRef.current = false;
setIsCreatingConnector(false);
setConnectingConnectorType(null);
}
}, [connectingConnectorType, searchSpaceId, createConnector, refetchAllConnectors]);
// Handle going back from connect view
const handleBackFromConnect = 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("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle starting indexing
const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => {
if (!indexingConfig || !searchSpaceId) return;
@ -494,11 +616,12 @@ export const useConnectorDialog = () => {
(oauthConnector) => oauthConnector.connectorType === connector.connector_type
);
// Check if this is webcrawler (can be managed in popup)
// Check if this is webcrawler or Tavily API (can be managed in popup)
const isWebcrawler = connector.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR;
const isTavilyApi = connector.connector_type === EnumConnectorName.TAVILY_API;
// If not OAuth and not webcrawler, redirect to old connector edit page
if (!isOAuthConnector && !isWebcrawler) {
// If not OAuth, not webcrawler, and not Tavily API, redirect to old connector edit page
if (!isOAuthConnector && !isWebcrawler && !isTavilyApi) {
router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`);
return;
}
@ -511,6 +634,7 @@ export const useConnectorDialog = () => {
}
setEditingConnector(connector);
setConnectorName(connector.name);
// Load existing periodic sync settings
setPeriodicEnabled(connector.periodic_indexing_enabled);
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
@ -530,8 +654,8 @@ export const useConnectorDialog = () => {
const handleSaveConnector = useCallback(async (refreshConnectors: () => void) => {
if (!editingConnector || !searchSpaceId) return;
// Validate date range (skip for Google Drive which uses folder selection, and Webcrawler which uses config)
if (editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR") {
// Validate date range (skip for Google Drive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
if (editingConnector.is_indexable && editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR") {
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
if (!dateRangeValidation.success) {
toast.error(dateRangeValidation.error.issues[0]?.message || "Invalid date range");
@ -539,8 +663,14 @@ export const useConnectorDialog = () => {
}
}
// Validate frequency minutes if periodic is enabled
if (periodicEnabled) {
// Prevent periodic indexing for non-indexable connectors
if (periodicEnabled && !editingConnector.is_indexable) {
toast.error("Periodic indexing is not available for this connector type");
return;
}
// Validate frequency minutes if periodic is enabled (only for indexable connectors)
if (periodicEnabled && editingConnector.is_indexable) {
const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes);
if (!frequencyValidation.success) {
toast.error("Invalid frequency value");
@ -553,20 +683,24 @@ export const useConnectorDialog = () => {
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
// Update connector with periodic sync settings, config changes, and name
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : null;
await updateConnector({
id: editingConnector.id,
data: {
name: connectorName || editingConnector.name,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: frequency,
config: connectorConfig || editingConnector.config,
},
});
// Re-index based on connector type
// Re-index based on connector type (only for indexable connectors)
let indexingDescription = "Settings saved.";
if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
if (!editingConnector.is_indexable) {
// Non-indexable connectors (like Tavily API) don't need re-indexing
indexingDescription = "Settings saved.";
} else 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) {
@ -628,7 +762,7 @@ export const useConnectorDialog = () => {
} finally {
setIsSaving(false);
}
}, [editingConnector, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, connectorConfig]);
}, [editingConnector, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, connectorConfig, connectorName]);
// Handle disconnecting connector
const handleDisconnectConnector = useCallback(async (refreshConnectors: () => void) => {
@ -692,12 +826,14 @@ export const useConnectorDialog = () => {
window.history.pushState({ modal: false }, "", url.toString());
setIsScrolled(false);
setSearchQuery("");
if (!isStartingIndexing && !isSaving && !isDisconnecting) {
if (!isStartingIndexing && !isSaving && !isDisconnecting && !isCreatingConnector) {
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
setConnectingConnectorType(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
@ -705,7 +841,7 @@ export const useConnectorDialog = () => {
}
}
},
[activeTab, isStartingIndexing, isDisconnecting, isSaving]
[activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector]
);
// Handle tab change
@ -735,6 +871,8 @@ export const useConnectorDialog = () => {
indexingConnector,
indexingConnectorConfig,
editingConnector,
connectingConnectorType,
isCreatingConnector,
startDate,
endDate,
isStartingIndexing,
@ -751,20 +889,24 @@ export const useConnectorDialog = () => {
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
setConnectorName,
// Handlers
handleOpenChange,
handleTabChange,
handleScroll,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTube,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,

View file

@ -13,6 +13,7 @@ interface AllConnectorsTabProps {
connectingId: string | null;
allConnectors: SearchSourceConnector[] | undefined;
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[0]) => void;
onConnectNonOAuth?: (connectorType: string) => void;
onCreateWebcrawler?: () => void;
onCreateYouTube?: () => void;
onManage?: (connector: SearchSourceConnector) => void;
@ -25,6 +26,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
connectingId,
allConnectors,
onConnectOAuth,
onConnectNonOAuth,
onCreateWebcrawler,
onCreateYouTube,
onManage,
@ -98,13 +100,16 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType)
: undefined;
// Special handling for webcrawler and YouTube - create in popup
// Special handling for connectors that can be created in popup
const isWebcrawler = connector.id === "webcrawler-connector";
const isYouTube = connector.id === "youtube-connector";
const isTavily = connector.id === "tavily-api";
const handleConnect = isWebcrawler && onCreateWebcrawler
? onCreateWebcrawler
: isYouTube && onCreateYouTube
? onCreateYouTube
: isTavily && onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType)
: () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`);
return (