feat: Introduce new connector schemas and validation, enhance connector dialog with improved query parameter handling, and implement scroll detection in indexing configuration view.

This commit is contained in:
Anish Sarkar 2025-12-30 21:25:48 +05:30
parent 32b4a09c0e
commit 29a3dcf091
8 changed files with 376 additions and 143 deletions

View file

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogSummary, LogActiveTask } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import {
TabsContent,
@ -20,7 +21,7 @@ interface ActiveConnectorsTabProps {
activeDocumentTypes: Array<[string, number]>;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: any;
logsSummary: LogSummary | undefined;
searchSpaceId: string;
onTabChange: (value: string) => void;
}
@ -67,7 +68,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{connectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: any) =>
(task: LogActiveTask) =>
task.source?.includes(`connector_${connector.id}`) ||
task.source?.includes(`connector-${connector.id}`)
);

View file

@ -1,10 +1,7 @@
"use client";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { type FC } from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants";
import { ConnectorCard } from "./connector-card";
@ -88,32 +85,24 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
const isConnected = connectedTypes.has(connector.connectorType);
return (
<Link
<ConnectorCard
key={connector.id}
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
className="group flex items-center gap-4 p-4 rounded-xl transition-all duration-150 border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
>
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{getConnectorIcon(connector.connectorType, "size-6")}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[14px] font-semibold leading-tight">
{connector.title}
</span>
{isConnected && (
<span
className="size-1.5 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"
title="Connected"
/>
)}
</div>
<p className="text-[11px] text-muted-foreground truncate mt-1">
{connector.description}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground/50 group-hover:text-foreground transition-colors flex-shrink-0" />
</Link>
id={connector.id}
title={connector.title}
description={connector.description}
connectorType={connector.connectorType}
isConnected={isConnected}
onConnect={() =>
router.push(
`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`
)
}
onManage={() =>
router.push(
`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`
)
}
/>
);
})}
</div>

View file

@ -34,12 +34,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[14px] font-semibold leading-tight">{title}</span>
{isConnected && (
<span
className="size-1.5 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"
title="Connected"
/>
)}
</div>
<p className="text-[11px] text-muted-foreground truncate mt-1">
{isConnected ? "Connected" : description}

View file

@ -2,6 +2,13 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
// OAuth Connectors (Quick Connect)
export const OAUTH_CONNECTORS = [
{
id: "google-drive-connector",
title: "Google Drive",
description: "Search your Drive files",
connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
authEndpoint: "/api/v1/auth/google/drive/connector/add/",
},
{
id: "google-gmail-connector",
title: "Gmail",
@ -125,10 +132,6 @@ export const OTHER_CONNECTORS = [
},
] as const;
// Type for the indexing configuration state
export interface IndexingConfigState {
connectorType: string;
connectorId: number;
connectorTitle: string;
}
// Re-export IndexingConfigState from schemas for backward compatibility
export type { IndexingConfigState } from "./connector-popup.schemas";

View file

@ -0,0 +1,108 @@
import { z } from "zod";
import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types";
/**
* Schema for URL query parameters used by the connector popup
*/
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
view: z.enum(["configure"]).optional(),
connector: z.string().optional(),
success: z.enum(["true", "false"]).optional(),
});
export type ConnectorPopupQueryParams = z.infer<typeof connectorPopupQueryParamsSchema>;
/**
* Schema for OAuth API response (auth_url)
*/
export const oauthAuthResponseSchema = z.object({
auth_url: z.string().url("Invalid auth URL format"),
});
export type OAuthAuthResponse = z.infer<typeof oauthAuthResponseSchema>;
/**
* Schema for IndexingConfigState
*/
export const indexingConfigStateSchema = z.object({
connectorType: searchSourceConnectorTypeEnum,
connectorId: z.number().int().positive("Connector ID must be a positive integer"),
connectorTitle: z.string().min(1, "Connector title is required"),
});
export type IndexingConfigState = z.infer<typeof indexingConfigStateSchema>;
/**
* Schema for frequency minutes (must be one of the allowed values)
*/
export const frequencyMinutesSchema = z.enum(["15", "60", "360", "720", "1440", "10080"], {
errorMap: () => ({ message: "Invalid frequency value" }),
});
export type FrequencyMinutes = z.infer<typeof frequencyMinutesSchema>;
/**
* Schema for date range validation
*/
export const dateRangeSchema = z
.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return data.startDate <= data.endDate;
}
return true;
},
{
message: "Start date must be before or equal to end date",
path: ["endDate"],
}
);
export type DateRange = z.infer<typeof dateRangeSchema>;
/**
* Schema for connector ID validation (used in URL params)
*/
export const connectorIdSchema = z.string().min(1, "Connector ID is required");
/**
* Helper function to safely parse query params
*/
export function parseConnectorPopupQueryParams(
params: URLSearchParams | Record<string, string | null>
): ConnectorPopupQueryParams {
const obj: Record<string, string | undefined> = {};
if (params instanceof URLSearchParams) {
params.forEach((value, key) => {
obj[key] = value || undefined;
});
} else {
Object.entries(params).forEach(([key, value]) => {
obj[key] = value || undefined;
});
}
return connectorPopupQueryParamsSchema.parse(obj);
}
/**
* Helper function to safely parse OAuth response
*/
export function parseOAuthAuthResponse(data: unknown): OAuthAuthResponse {
return oauthAuthResponseSchema.parse(data);
}
/**
* Helper function to validate indexing config state
*/
export function validateIndexingConfigState(data: unknown): IndexingConfigState {
return indexingConfigStateSchema.parse(data);
}

View file

@ -14,6 +14,24 @@ export { ActiveConnectorsTab } from "./active-connectors-tab";
export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants";
export type { IndexingConfigState } from "./connector-constants";
// Schemas and validation
export {
connectorPopupQueryParamsSchema,
oauthAuthResponseSchema,
indexingConfigStateSchema,
frequencyMinutesSchema,
dateRangeSchema,
parseConnectorPopupQueryParams,
parseOAuthAuthResponse,
validateIndexingConfigState,
} from "./connector-popup.schemas";
export type {
ConnectorPopupQueryParams,
OAuthAuthResponse,
FrequencyMinutes,
DateRange,
} from "./connector-popup.schemas";
// Hooks
export { useConnectorDialog } from "./use-connector-dialog";

View file

@ -1,9 +1,10 @@
"use client";
import { ArrowLeft, Check, Loader2 } from "lucide-react";
import { type FC } from "react";
import { type FC, useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
import type { IndexingConfigState } from "./connector-constants";
import { DateRangeSelector } from "./date-range-selector";
import { PeriodicSyncConfig } from "./periodic-sync-config";
@ -37,10 +38,49 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onStartIndexing,
onSkip,
}) => {
const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = 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]);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */}
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<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"
@ -68,39 +108,53 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
</div>
{/* Scrollable Content */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
<div className="space-y-6 pb-6">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
<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">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
/>
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
{getConnectorIcon(config.connectorType, "size-4")}
</div>
<div className="text-sm">
<p className="font-medium">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>
{/* 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(config.connectorType, "size-4")}
</div>
<div className="text-sm">
<p className="font-medium">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 border-t border-border bg-muted">
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted">
<Button variant="ghost" onClick={onSkip} disabled={isStartingIndexing}>
Skip for now
</Button>

View file

@ -10,8 +10,16 @@ import { queryClient } from "@/lib/query-client/client";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { format } from "date-fns";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { searchSourceConnector } from "@/contracts/types/connector.types";
import { OAUTH_CONNECTORS } from "./connector-constants";
import type { IndexingConfigState } from "./connector-constants";
import {
parseConnectorPopupQueryParams,
parseOAuthAuthResponse,
validateIndexingConfigState,
frequencyMinutesSchema,
dateRangeSchema,
} from "./connector-popup.schemas";
export const useConnectorDialog = () => {
const router = useRouter();
@ -48,65 +56,101 @@ export const useConnectorDialog = () => {
// Synchronize state with URL query params
useEffect(() => {
const modalParam = searchParams.get("modal");
const tabParam = searchParams.get("tab");
const viewParam = searchParams.get("view");
const connectorParam = searchParams.get("connector");
if (modalParam === "connectors") {
if (!isOpen) setIsOpen(true);
try {
const params = parseConnectorPopupQueryParams(searchParams);
if (tabParam === "active" || tabParam === "all") {
if (activeTab !== tabParam) setActiveTab(tabParam);
}
if (viewParam === "configure" && connectorParam && !indexingConfig) {
const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam);
if (oauthConnector && allConnectors) {
const existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
if (existingConnector) {
setIndexingConfig({
connectorType: oauthConnector.connectorType,
connectorId: existingConnector.id,
connectorTitle: oauthConnector.title,
});
if (params.modal === "connectors") {
setIsOpen(true);
if (params.tab === "active" || params.tab === "all") {
setActiveTab(params.tab);
}
// Clear indexing config if view is not "configure" anymore
if (params.view !== "configure" && indexingConfig) {
setIndexingConfig(null);
}
if (params.view === "configure" && params.connector && !indexingConfig) {
const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector);
if (oauthConnector && allConnectors) {
const existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
if (existingConnector) {
// Validate connector data before setting state
const connectorValidation = searchSourceConnector.safeParse(existingConnector);
if (connectorValidation.success) {
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: existingConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
}
}
}
}
} else {
setIsOpen(false);
// Clear indexing config when modal is closed
if (indexingConfig) {
setIndexingConfig(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
setFrequencyMinutes("1440");
setIsScrolled(false);
setSearchQuery("");
}
}
} else {
if (isOpen) setIsOpen(false);
} catch (error) {
// Invalid query params - log but don't crash
console.warn("Invalid connector popup query params:", error);
}
}, [searchParams, isOpen, activeTab, indexingConfig, allConnectors]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, allConnectors]);
// Detect OAuth success and transition to config view
useEffect(() => {
const success = searchParams.get("success");
const connectorParam = searchParams.get("connector");
const modalParam = searchParams.get("modal");
if (success === "true" && connectorParam && searchSpaceId && modalParam === "connectors") {
const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam);
if (oauthConnector) {
refetchAllConnectors().then((result) => {
const newConnector = result.data?.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
if (newConnector) {
setIndexingConfig({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
}
});
try {
const params = parseConnectorPopupQueryParams(searchParams);
if (params.success === "true" && params.connector && searchSpaceId && params.modal === "connectors") {
const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector);
if (oauthConnector) {
refetchAllConnectors().then((result) => {
if (!result.data) return;
const newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
if (newConnector) {
// Validate connector data before setting state
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
} else {
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
toast.error("Failed to validate connector data");
}
}
});
}
}
} catch (error) {
// Invalid query params - log but don't crash
console.warn("Invalid connector popup query params in OAuth success handler:", error);
}
}, [searchParams, searchSpaceId, refetchAllConnectors]);
@ -115,8 +159,10 @@ export const useConnectorDialog = () => {
async (connector: (typeof OAUTH_CONNECTORS)[0]) => {
if (!searchSpaceId || !connector.authEndpoint) return;
// Set connecting state immediately to disable button and show spinner
setConnectingId(connector.id);
try {
setConnectingId(connector.id);
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`,
{ method: "GET" }
@ -127,11 +173,21 @@ export const useConnectorDialog = () => {
}
const data = await response.json();
window.location.href = data.auth_url;
// Validate OAuth response with Zod
const validatedData = parseOAuthAuthResponse(data);
// Don't clear connectingId here - let the redirect happen with button still disabled
// The component will unmount on redirect anyway
window.location.href = validatedData.auth_url;
} catch (error) {
console.error(`Error connecting to ${connector.title}:`, error);
toast.error(`Failed to connect to ${connector.title}`);
} finally {
if (error instanceof Error && error.message.includes("Invalid auth URL")) {
toast.error(`Invalid response from ${connector.title} OAuth endpoint`);
} else {
toast.error(`Failed to connect to ${connector.title}`);
}
// Only clear connectingId on error so user can retry
setConnectingId(null);
}
},
@ -142,6 +198,22 @@ export const useConnectorDialog = () => {
const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => {
if (!indexingConfig || !searchSpaceId) return;
// Validate date range
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
if (!dateRangeValidation.success) {
toast.error(dateRangeValidation.error.errors[0]?.message || "Invalid date range");
return;
}
// Validate frequency minutes if periodic is enabled
if (periodicEnabled) {
const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes);
if (!frequencyValidation.success) {
toast.error("Invalid frequency value");
return;
}
}
setIsStartingIndexing(true);
try {
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
@ -173,18 +245,14 @@ export const useConnectorDialog = () => {
: "You can continue working while we sync your data.",
});
setIndexingConfig(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
setFrequencyMinutes("1440");
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
url.searchParams.delete("view");
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("success");
url.searchParams.delete("connector");
url.searchParams.set("tab", "active");
window.history.replaceState({}, "", url.toString());
setActiveTab("active");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
refreshConnectors();
queryClient.invalidateQueries({
@ -196,21 +264,19 @@ export const useConnectorDialog = () => {
} finally {
setIsStartingIndexing(false);
}
}, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel]);
}, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router]);
// Handle skipping indexing
const handleSkipIndexing = useCallback(() => {
setIndexingConfig(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
setFrequencyMinutes("1440");
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
url.searchParams.delete("view");
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("success");
url.searchParams.delete("connector");
window.history.replaceState({}, "", url.toString());
}, []);
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle dialog open/close
const handleOpenChange = useCallback(