feat: Implement connector editing functionality in the popup, including Google Drive folder selection, and enhance connector management with improved state handling and UI updates.

This commit is contained in:
Anish Sarkar 2025-12-31 02:00:11 +05:30
parent 29a3dcf091
commit ddfbb9509b
19 changed files with 1182 additions and 446 deletions

View file

@ -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:

View file

@ -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 = () => {
</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">
{/* Indexing Configuration View - shown after OAuth success */}
{indexingConfig ? (
{/* Connector Edit View - shown when editing existing connector */}
{editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onSave={() => handleSaveConnector(refreshConnectors)}
onDisconnect={() => handleDisconnectConnector(refreshConnectors)}
onBack={handleBackFromEdit}
onConfigChange={setConnectorConfig}
/>
) : indexingConfig ? (
<IndexingConfigurationView
config={indexingConfig}
connector={indexingConnector ? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
} : undefined}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
@ -146,6 +184,7 @@ export const ConnectorIndicator: FC = () => {
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}
/>
</TabsContent>
@ -184,6 +225,7 @@ export const ConnectorIndicator: FC = () => {
logsSummary={logsSummary}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}
/>
</div>
</div>

View file

@ -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<ConnectorConfigProps> = ({
connector,
onConfigChange,
}) => {
// Initialize with existing selected folders from connector config
const existingFolders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(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 (
<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">Folder Selection</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Select specific folders to index. Only files directly in each folder will be processedsubfolders must be selected separately.
</p>
</div>
{selectedFolders.length > 0 && (
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
<p className="font-medium">
Selected {selectedFolders.length} folder{selectedFolders.length > 1 ? "s" : ""}:
</p>
<div className="max-h-20 sm:max-h-24 overflow-y-auto">
{selectedFolders.map((folder) => (
<p key={folder.id} className="text-xs sm:text-sm text-muted-foreground truncate" title={folder.name}>
{folder.name}
</p>
))}
</div>
</div>
)}
{showFolderSelector ? (
<div className="space-y-2 sm:space-y-3">
<GoogleDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowFolderSelector(false)}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
Done Selecting
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
onClick={() => setShowFolderSelector(true)}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{selectedFolders.length > 0 ? "Change Folder Selection" : "Select Folders"}
</Button>
)}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
Folder selection is used when indexing. You can change this selection when you start indexing.
</AlertDescription>
</Alert>
</div>
);
};

View file

@ -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<string, unknown>) => void;
}
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
/**
* 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;
}
}

View file

@ -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<string, any>) => void;
}
export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
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<HTMLDivElement>(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 (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */}
<div className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm"
)}>
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
>
<ArrowLeft className="size-4" />
Back to connectors
</button>
{/* Connector header */}
<div className="flex items-center gap-4 mb-6">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20">
{getConnectorIcon(connector.connector_type, "size-7")}
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold tracking-tight">
{connector.name}
</h2>
<p className="text-muted-foreground mt-1">
Manage your connector settings and sync configuration
</p>
</div>
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto px-6 sm:px-12"
onScroll={handleScroll}
>
<div className="space-y-6 pb-6 pt-2">
{/* Connector-specific configuration */}
{ConnectorConfigComponent && (
<ConnectorConfigComponent
connector={connector}
onConfigChange={onConfigChange}
/>
)}
{/* Date range selector - not shown for Google Drive (uses folder selection instead) */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
)}
<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">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
{getConnectorIcon(connector.connector_type, "size-4")}
</div>
<div className="text-sm">
<p className="font-medium">Re-indexing runs in the background</p>
<p className="text-muted-foreground mt-1">
You can continue using SurfSense while we sync your data. Check the Active tab to see progress.
</p>
</div>
</div>
</div>
</div>
{/* Top fade shadow - appears when scrolled */}
{isScrolled && (
<div className="absolute top-0 left-0 right-0 h-6 bg-gradient-to-b from-muted/50 to-transparent pointer-events-none z-10" />
)}
{/* Bottom fade shadow - appears when there's more content */}
{hasMoreContent && (
<div className="absolute bottom-0 left-0 right-0 h-3 bg-gradient-to-t from-muted/50 to-transparent pointer-events-none z-10" />
)}
</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">
{showDisconnectConfirm ? (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Are you sure?</span>
<Button
variant="destructive"
size="sm"
onClick={handleDisconnectConfirm}
disabled={isDisconnecting}
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Disconnecting...
</>
) : (
"Confirm Disconnect"
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDisconnectCancel}
disabled={isDisconnecting}
>
Cancel
</Button>
</div>
) : (
<Button
variant="destructive"
size="sm"
onClick={handleDisconnectClick}
disabled={isSaving || isDisconnecting}
>
<Trash2 className="mr-2 h-4 w-4" />
Disconnect
</Button>
)}
<Button onClick={onSave} disabled={isSaving || isDisconnecting}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</div>
</div>
);
};

View file

@ -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<string, unknown>) => void;
onStartIndexing: () => void;
onSkip: () => void;
}
export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
config,
connector,
startDate,
endDate,
periodicEnabled,
@ -35,9 +40,15 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
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<HTMLDivElement>(null);
@ -115,12 +126,23 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onScroll={handleScroll}
>
<div className="space-y-6 pb-6 pt-2">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
{/* Connector-specific configuration */}
{ConnectorConfigComponent && connector && (
<ConnectorConfigComponent
connector={connector}
onConfigChange={onConfigChange}
/>
)}
{/* Date range selector - not shown for Google Drive (uses folder selection instead) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
)}
<PeriodicSyncConfig
enabled={periodicEnabled}

View file

@ -32,7 +32,7 @@ export const OAUTH_CONNECTORS = [
},
] as const;
// Non-OAuth Connectors
// Non-OAuth Connectors (redirect to old connector config pages)
export const OTHER_CONNECTORS = [
{
id: "slack-connector",

View file

@ -7,8 +7,9 @@ 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"]).optional(),
view: z.enum(["configure", "edit"]).optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
success: z.enum(["true", "false"]).optional(),
});

View file

@ -0,0 +1,637 @@
import { useAtomValue } from "jotai";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { deleteConnectorMutationAtom, 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 "../constants/connector-constants";
import type { IndexingConfigState } from "../constants/connector-constants";
import {
parseConnectorPopupQueryParams,
parseOAuthAuthResponse,
validateIndexingConfigState,
frequencyMinutesSchema,
dateRangeSchema,
} from "../constants/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 { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("all");
const [connectingId, setConnectingId] = useState<string | null>(null);
const [isScrolled, setIsScrolled] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [indexingConfig, setIndexingConfig] = useState<IndexingConfigState | null>(null);
const [indexingConnector, setIndexingConnector] = useState<SearchSourceConnector | null>(null);
const [indexingConnectorConfig, setIndexingConnectorConfig] = useState<Record<string, unknown> | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [isStartingIndexing, setIsStartingIndexing] = useState(false);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
// Edit mode state
const [editingConnector, setEditingConnector] = useState<SearchSourceConnector | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [connectorConfig, setConnectorConfig] = useState<Record<string, unknown> | 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<HTMLDivElement>) => {
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,
};
};

View file

@ -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";

View file

@ -24,6 +24,7 @@ interface ActiveConnectorsTabProps {
logsSummary: LogSummary | undefined;
searchSpaceId: string;
onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void;
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
@ -34,6 +35,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
logsSummary,
searchSpaceId,
onTabChange,
onManage,
}) => {
const router = useRouter();
@ -119,11 +121,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
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"}

View file

@ -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<string>;
connectingId: string | null;
allConnectors: SearchSourceConnector[] | undefined;
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[0]) => void;
onManage?: (connector: SearchSourceConnector) => void;
}
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
@ -18,7 +21,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
searchSpaceId,
connectedTypes,
connectingId,
allConnectors,
onConnectOAuth,
onManage,
}) => {
const router = useRouter();
@ -49,6 +54,10 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
{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 (
<ConnectorCard
@ -60,11 +69,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
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<AllConnectorsTabProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{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 (
<ConnectorCard
@ -97,11 +106,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`
)
}
onManage={() =>
router.push(
`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`
)
}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
})}

View file

@ -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<string | null>(null);
const [isScrolled, setIsScrolled] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [indexingConfig, setIndexingConfig] = useState<IndexingConfigState | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(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<HTMLDivElement>) => {
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,
};
};

View file

@ -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 (
<div key={item.id} className="w-full" style={{ marginLeft: `${level * 1.25}rem` }}>
<div key={item.id} className="w-full sm:ml-[calc(var(--level)*1.25rem)]" style={{ marginLeft: `${level * indentSize}rem`, '--level': level } as React.CSSProperties & { '--level'?: number }}>
<div
className={cn(
"flex items-center gap-2 h-auto py-2 px-2 rounded-md",
"flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
isFolder && "hover:bg-accent cursor-pointer",
!isFolder && "cursor-default opacity-60",
isSelected && isFolder && "bg-accent/50"
)}
>
{isFolder ? (
<span
className="flex items-center justify-center w-4 h-4 shrink-0"
<button
type="button"
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
toggleFolder(item);
}}
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-2.5 w-2.5 sm:h-3 sm:w-3 animate-spin" />
) : isExpanded ? (
<ChevronDown className="h-4 w-4" />
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
) : (
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</span>
</button>
) : (
<span className="w-4 h-4 shrink-0" />
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
)}
{isFolder && (
<Checkbox
checked={isSelected}
onCheckedChange={() => 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()}
/>
)}
<div className="shrink-0" style={{ marginLeft: isFolder ? "0" : "1.25rem" }}>
<div className={cn("shrink-0", !isFolder && "ml-3 sm:ml-5")}>
{isFolder ? (
isExpanded ? (
<FolderOpen className="h-4 w-4 text-blue-500" />
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 text-blue-500" />
) : (
<Folder className="h-4 w-4 text-gray-500" />
<Folder className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
)
) : (
getFileIcon(item.mimeType, "h-4 w-4")
getFileIcon(item.mimeType, "h-3 w-3 sm:h-4 sm:w-4")
)}
</div>
<span
className="truncate flex-1 text-left text-sm min-w-0"
onClick={() => isFolder && toggleFolder(item)}
>
{item.name}
</span>
{isFolder ? (
<button
type="button"
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer"
onClick={() => toggleFolder(item)}
>
{item.name}
</button>
) : (
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
{item.name}
</span>
)}
</div>
{isExpanded && isFolder && children && (
@ -272,7 +282,7 @@ export function GoogleDriveFolderTree({
{childFiles.map((child) => renderItem(child, level + 1))}
{children.length === 0 && (
<div className="text-xs text-muted-foreground py-2 pl-2">Empty folder</div>
<div className="text-[10px] sm:text-xs text-muted-foreground py-1 sm:py-2 pl-1 sm:pl-2">Empty folder</div>
)}
</div>
)}
@ -282,25 +292,29 @@ export function GoogleDriveFolderTree({
return (
<div className="border rounded-md w-full overflow-hidden">
<ScrollArea className="h-[450px] w-full">
<div className="p-2 pr-4 w-full overflow-x-hidden">
<div className="mb-2 pb-2 border-b">
<div className="flex items-center gap-2 h-auto py-2 px-2 rounded-md hover:bg-accent cursor-pointer">
<ScrollArea className="h-[300px] sm:h-[450px] w-full">
<div className="p-1 sm:p-2 pr-2 sm:pr-4 w-full overflow-x-hidden">
<div className="mb-1 sm:mb-2 pb-1 sm:pb-2 border-b">
<div className="flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md hover:bg-accent cursor-pointer">
<Checkbox
checked={isFolderSelected("root")}
onCheckedChange={() => toggleFolderSelection("root", "My Drive")}
className="shrink-0"
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4"
/>
<HardDrive className="h-4 w-4 text-primary shrink-0" />
<span className="font-semibold truncate" onClick={() => toggleFolderSelection("root", "My Drive")}>
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-primary shrink-0" />
<button
type="button"
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
onClick={() => toggleFolderSelection("root", "My Drive")}
>
My Drive
</span>
</button>
</div>
</div>
{isLoadingRoot && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="flex items-center justify-center py-4 sm:py-8">
<Loader2 className="h-4 w-4 sm:h-6 sm:w-6 animate-spin text-muted-foreground" />
</div>
)}
@ -309,7 +323,7 @@ export function GoogleDriveFolderTree({
</div>
{!isLoadingRoot && rootItems.length === 0 && (
<div className="text-center text-sm text-muted-foreground py-8">
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
No files or folders found in your Google Drive
</div>
)}

View file

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/20",
className
)}
{...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=checked]:bg-background data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-muted-foreground/40"
)}
/>
</SwitchPrimitives.Root>