mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
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:
parent
29a3dcf091
commit
ddfbb9509b
19 changed files with 1182 additions and 446 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 processed—subfolders 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
@ -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",
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue