mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-12 23:29:44 +00:00
refactor edit connector page for smaller file size
This commit is contained in:
parent
5c81c6037d
commit
53436370d6
4 changed files with 312 additions and 342 deletions
|
@ -1,372 +1,90 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import React, { useEffect } from 'react';
|
||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ArrowLeft, Check, Loader2, Github, } from "lucide-react";
|
import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
|
||||||
|
|
||||||
import { useSearchSourceConnectors, SearchSourceConnector } from "@/hooks/useSearchSourceConnectors";
|
import { Form } from "@/components/ui/form";
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
// Import Utils, Types, Hook, and Components
|
||||||
CardDescription,
|
import { getConnectorTypeDisplay } from '@/lib/connectors/utils';
|
||||||
CardFooter,
|
import { useConnectorEditPage } from '@/hooks/useConnectorEditPage';
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
|
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
|
||||||
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
|
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
|
||||||
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
|
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
|
||||||
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
|
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
|
||||||
|
|
||||||
|
export default function EditConnectorPage() {
|
||||||
// Helper function to get connector type display name (copied from manage page)
|
|
||||||
const getConnectorTypeDisplay = (type: string): string => {
|
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
"SERPER_API": "Serper API",
|
|
||||||
"TAVILY_API": "Tavily API",
|
|
||||||
"SLACK_CONNECTOR": "Slack",
|
|
||||||
"NOTION_CONNECTOR": "Notion",
|
|
||||||
"GITHUB_CONNECTOR": "GitHub",
|
|
||||||
};
|
|
||||||
return typeMap[type] || type;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Schema for PAT input when editing GitHub repos (remains separate)
|
|
||||||
const githubPatSchema = z.object({
|
|
||||||
github_pat: z.string()
|
|
||||||
.min(20, { message: "GitHub Personal Access Token seems too short." })
|
|
||||||
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), {
|
|
||||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
type GithubPatFormValues = z.infer<typeof githubPatSchema>;
|
|
||||||
|
|
||||||
// Updated schema for main edit form - includes optional fields for other connector configs
|
|
||||||
const editConnectorSchema = z.object({
|
|
||||||
name: z.string().min(3, { message: "Connector name must be at least 3 characters." }),
|
|
||||||
// Add optional fields for other connector types' configs
|
|
||||||
SLACK_BOT_TOKEN: z.string().optional(),
|
|
||||||
NOTION_INTEGRATION_TOKEN: z.string().optional(),
|
|
||||||
SERPER_API_KEY: z.string().optional(),
|
|
||||||
TAVILY_API_KEY: z.string().optional(),
|
|
||||||
// GITHUB_PAT is handled separately via patForm for repo editing flow
|
|
||||||
});
|
|
||||||
type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;
|
|
||||||
|
|
||||||
interface GithubRepo {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
full_name: string;
|
|
||||||
private: boolean;
|
|
||||||
url: string;
|
|
||||||
description: string | null;
|
|
||||||
last_updated: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditMode = 'viewing' | 'editing_repos'; // Only relevant for GitHub
|
|
||||||
|
|
||||||
export default function EditConnectorPage() { // Renamed for clarity
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const connectorId = parseInt(params.connector_id as string, 10);
|
// Ensure connectorId is parsed safely
|
||||||
|
const connectorIdParam = params.connector_id as string;
|
||||||
|
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||||
|
|
||||||
const { connectors, updateConnector, isLoading: connectorsLoading } = useSearchSourceConnectors();
|
// Use the custom hook to manage state and logic
|
||||||
|
const {
|
||||||
|
connectorsLoading,
|
||||||
|
connector,
|
||||||
|
isSaving,
|
||||||
|
editForm,
|
||||||
|
patForm, // Needed for GitHub child component
|
||||||
|
handleSaveChanges,
|
||||||
|
// GitHub specific props for the child component
|
||||||
|
editMode,
|
||||||
|
setEditMode, // Pass down if needed by GitHub component
|
||||||
|
originalPat,
|
||||||
|
currentSelectedRepos,
|
||||||
|
fetchedRepos,
|
||||||
|
setFetchedRepos,
|
||||||
|
newSelectedRepos,
|
||||||
|
setNewSelectedRepos,
|
||||||
|
isFetchingRepos,
|
||||||
|
handleFetchRepositories,
|
||||||
|
handleRepoSelectionChange,
|
||||||
|
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||||
|
|
||||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
// Redirect if connectorId is not a valid number after parsing
|
||||||
const [originalConfig, setOriginalConfig] = useState<Record<string, any> | null>(null); // Store original config object
|
|
||||||
|
|
||||||
// GitHub specific state (only used if connector type is GitHub)
|
|
||||||
const [currentSelectedRepos, setCurrentSelectedRepos] = useState<string[]>([]);
|
|
||||||
const [originalPat, setOriginalPat] = useState<string>("");
|
|
||||||
const [editMode, setEditMode] = useState<EditMode>('viewing');
|
|
||||||
const [fetchedRepos, setFetchedRepos] = useState<GithubRepo[] | null>(null);
|
|
||||||
const [newSelectedRepos, setNewSelectedRepos] = useState<string[]>([]);
|
|
||||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
// Form for GitHub PAT input (only used for GitHub repo editing)
|
|
||||||
const patForm = useForm<GithubPatFormValues>({
|
|
||||||
resolver: zodResolver(githubPatSchema),
|
|
||||||
defaultValues: { github_pat: "" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main form for connector details (name + simple config fields)
|
|
||||||
const editForm = useForm<EditConnectorFormValues>({
|
|
||||||
resolver: zodResolver(editConnectorSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
SLACK_BOT_TOKEN: "",
|
|
||||||
NOTION_INTEGRATION_TOKEN: "",
|
|
||||||
SERPER_API_KEY: "",
|
|
||||||
TAVILY_API_KEY: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Effect to load connector data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectorsLoading && connectors.length > 0 && !connector) {
|
if (isNaN(connectorId)) {
|
||||||
const currentConnector = connectors.find(c => c.id === connectorId);
|
toast.error("Invalid Connector ID.");
|
||||||
if (currentConnector) {
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
setConnector(currentConnector);
|
|
||||||
setOriginalConfig(currentConnector.config || {}); // Store original config
|
|
||||||
|
|
||||||
// Reset main form with common and type-specific fields
|
|
||||||
editForm.reset({
|
|
||||||
name: currentConnector.name,
|
|
||||||
SLACK_BOT_TOKEN: currentConnector.config?.SLACK_BOT_TOKEN || "",
|
|
||||||
NOTION_INTEGRATION_TOKEN: currentConnector.config?.NOTION_INTEGRATION_TOKEN || "",
|
|
||||||
SERPER_API_KEY: currentConnector.config?.SERPER_API_KEY || "",
|
|
||||||
TAVILY_API_KEY: currentConnector.config?.TAVILY_API_KEY || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// If GitHub, set up GitHub-specific state
|
|
||||||
if (currentConnector.connector_type === 'GITHUB_CONNECTOR') {
|
|
||||||
const savedRepos = currentConnector.config?.repo_full_names || [];
|
|
||||||
const savedPat = currentConnector.config?.GITHUB_PAT || "";
|
|
||||||
setCurrentSelectedRepos(savedRepos);
|
|
||||||
setNewSelectedRepos(savedRepos);
|
|
||||||
setOriginalPat(savedPat);
|
|
||||||
patForm.reset({ github_pat: savedPat });
|
|
||||||
setEditMode('viewing'); // Start in viewing mode for repos
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("Connector not found.");
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [connectorId, connectors, connectorsLoading, router, searchSpaceId, connector, editForm, patForm]);
|
}, [connectorId, router, searchSpaceId]);
|
||||||
|
|
||||||
// Fetch repositories using the entered PAT
|
|
||||||
const handleFetchRepositories = async (values: GithubPatFormValues) => {
|
|
||||||
setIsFetchingRepos(true);
|
|
||||||
setFetchedRepos(null);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('surfsense_bearer_token');
|
|
||||||
if (!token) throw new Error('No authentication token found');
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ github_pat: values.github_pat })
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const data: GithubRepo[] = await response.json();
|
|
||||||
setFetchedRepos(data);
|
|
||||||
setNewSelectedRepos(currentSelectedRepos);
|
|
||||||
toast.success(`Found ${data.length} repositories. Select which ones to index.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching GitHub repositories:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to fetch repositories.");
|
|
||||||
} finally {
|
|
||||||
setIsFetchingRepos(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle checkbox changes during editing
|
|
||||||
const handleRepoSelectionChange = (repoFullName: string, checked: boolean) => {
|
|
||||||
setNewSelectedRepos(prev =>
|
|
||||||
checked
|
|
||||||
? [...prev, repoFullName]
|
|
||||||
: prev.filter(name => name !== repoFullName)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save changes - updated to handle different connector types
|
|
||||||
const handleSaveChanges = async (formData: EditConnectorFormValues) => {
|
|
||||||
if (!connector || !originalConfig) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
const updatePayload: Partial<SearchSourceConnector> = {};
|
|
||||||
let configChanged = false;
|
|
||||||
let newConfig: Record<string, any> | null = null;
|
|
||||||
|
|
||||||
// 1. Check if name changed
|
|
||||||
if (formData.name !== connector.name) {
|
|
||||||
updatePayload.name = formData.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check for config changes based on connector type
|
|
||||||
switch (connector.connector_type) {
|
|
||||||
case 'GITHUB_CONNECTOR':
|
|
||||||
const currentPatInForm = patForm.getValues('github_pat');
|
|
||||||
const patChanged = currentPatInForm !== originalPat;
|
|
||||||
const initialRepoSet = new Set(currentSelectedRepos);
|
|
||||||
const newRepoSet = new Set(newSelectedRepos);
|
|
||||||
const reposChanged = initialRepoSet.size !== newRepoSet.size || ![...initialRepoSet].every(repo => newRepoSet.has(repo));
|
|
||||||
|
|
||||||
if (patChanged || (editMode === 'editing_repos' && reposChanged && fetchedRepos !== null)) {
|
|
||||||
if (!currentPatInForm || !(currentPatInForm.startsWith('ghp_') || currentPatInForm.startsWith('github_pat_'))) {
|
|
||||||
toast.error("Invalid GitHub PAT format. Cannot save.");
|
|
||||||
setIsSaving(false); return;
|
|
||||||
}
|
|
||||||
newConfig = {
|
|
||||||
GITHUB_PAT: currentPatInForm,
|
|
||||||
repo_full_names: newSelectedRepos,
|
|
||||||
};
|
|
||||||
if (reposChanged && newSelectedRepos.length === 0) {
|
|
||||||
toast.warning("Warning: No repositories selected.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SLACK_CONNECTOR':
|
|
||||||
if (formData.SLACK_BOT_TOKEN !== originalConfig.SLACK_BOT_TOKEN) {
|
|
||||||
if (!formData.SLACK_BOT_TOKEN) {
|
|
||||||
toast.error("Slack Bot Token cannot be empty."); setIsSaving(false); return;
|
|
||||||
}
|
|
||||||
newConfig = { SLACK_BOT_TOKEN: formData.SLACK_BOT_TOKEN };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'NOTION_CONNECTOR':
|
|
||||||
if (formData.NOTION_INTEGRATION_TOKEN !== originalConfig.NOTION_INTEGRATION_TOKEN) {
|
|
||||||
if (!formData.NOTION_INTEGRATION_TOKEN) {
|
|
||||||
toast.error("Notion Integration Token cannot be empty."); setIsSaving(false); return;
|
|
||||||
}
|
|
||||||
newConfig = { NOTION_INTEGRATION_TOKEN: formData.NOTION_INTEGRATION_TOKEN };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SERPER_API':
|
|
||||||
if (formData.SERPER_API_KEY !== originalConfig.SERPER_API_KEY) {
|
|
||||||
if (!formData.SERPER_API_KEY) {
|
|
||||||
toast.error("Serper API Key cannot be empty."); setIsSaving(false); return;
|
|
||||||
}
|
|
||||||
newConfig = { SERPER_API_KEY: formData.SERPER_API_KEY };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'TAVILY_API':
|
|
||||||
if (formData.TAVILY_API_KEY !== originalConfig.TAVILY_API_KEY) {
|
|
||||||
if (!formData.TAVILY_API_KEY) {
|
|
||||||
toast.error("Tavily API Key cannot be empty."); setIsSaving(false); return;
|
|
||||||
}
|
|
||||||
newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Add cases for other connector types if necessary
|
|
||||||
}
|
|
||||||
|
|
||||||
// If config was determined to have changed, add it to the payload
|
|
||||||
if (newConfig !== null) {
|
|
||||||
updatePayload.config = newConfig;
|
|
||||||
configChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check if there are actual changes to save
|
|
||||||
if (Object.keys(updatePayload).length === 0) {
|
|
||||||
toast.info("No changes detected.");
|
|
||||||
setIsSaving(false);
|
|
||||||
if (connector.connector_type === 'GITHUB_CONNECTOR') {
|
|
||||||
setEditMode('viewing');
|
|
||||||
patForm.reset({ github_pat: originalPat });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Proceed with update API call
|
|
||||||
try {
|
|
||||||
await updateConnector(connectorId, updatePayload);
|
|
||||||
toast.success("Connector updated successfully!");
|
|
||||||
|
|
||||||
// Update local state after successful save
|
|
||||||
const newlySavedConfig = updatePayload.config || originalConfig;
|
|
||||||
setOriginalConfig(newlySavedConfig);
|
|
||||||
if (updatePayload.name) {
|
|
||||||
setConnector(prev => prev ? { ...prev, name: updatePayload.name!, config: newlySavedConfig } : null);
|
|
||||||
editForm.setValue('name', updatePayload.name);
|
|
||||||
} else {
|
|
||||||
setConnector(prev => prev ? { ...prev, config: newlySavedConfig } : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connector.connector_type === 'GITHUB_CONNECTOR' && configChanged) {
|
|
||||||
const savedGitHubConfig = newlySavedConfig as { GITHUB_PAT?: string; repo_full_names?: string[] };
|
|
||||||
setCurrentSelectedRepos(savedGitHubConfig.repo_full_names || []);
|
|
||||||
setOriginalPat(savedGitHubConfig.GITHUB_PAT || "");
|
|
||||||
setNewSelectedRepos(savedGitHubConfig.repo_full_names || []);
|
|
||||||
patForm.reset({ github_pat: savedGitHubConfig.GITHUB_PAT || "" });
|
|
||||||
} else if (connector.connector_type === 'SLACK_CONNECTOR' && configChanged) {
|
|
||||||
editForm.setValue('SLACK_BOT_TOKEN', newlySavedConfig.SLACK_BOT_TOKEN || "");
|
|
||||||
} // Add similar blocks for Notion, Serper, Tavily
|
|
||||||
else if (connector.connector_type === 'NOTION_CONNECTOR' && configChanged) {
|
|
||||||
editForm.setValue('NOTION_INTEGRATION_TOKEN', newlySavedConfig.NOTION_INTEGRATION_TOKEN || "");
|
|
||||||
} else if (connector.connector_type === 'SERPER_API' && configChanged) {
|
|
||||||
editForm.setValue('SERPER_API_KEY', newlySavedConfig.SERPER_API_KEY || "");
|
|
||||||
} else if (connector.connector_type === 'TAVILY_API' && configChanged) {
|
|
||||||
editForm.setValue('TAVILY_API_KEY', newlySavedConfig.TAVILY_API_KEY || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset GitHub specific edit state
|
|
||||||
if (connector.connector_type === 'GITHUB_CONNECTOR') {
|
|
||||||
setEditMode('viewing');
|
|
||||||
setFetchedRepos(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating connector:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to update connector.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Loading State
|
||||||
if (connectorsLoading || !connector) {
|
if (connectorsLoading || !connector) {
|
||||||
|
// Handle NaN case before showing skeleton
|
||||||
|
if (isNaN(connectorId)) return null;
|
||||||
return <EditConnectorLoadingSkeleton />;
|
return <EditConnectorLoadingSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Main Render using data/handlers from the hook
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<Button
|
<Button variant="ghost" className="mb-6" onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}>
|
||||||
variant="ghost"
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
|
||||||
className="mb-6"
|
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Connectors
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
{/* Title can be dynamic based on type */}
|
|
||||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
<Github className="h-6 w-6" /> {/* TODO: Make icon dynamic */}
|
<Github className="h-6 w-6" /> {/* TODO: Dynamic icon */}
|
||||||
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Modify connector name and configuration.</CardDescription>
|
||||||
Modify the connector name and configuration.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<Form {...editForm}>
|
<Form {...editForm}>
|
||||||
|
{/* Pass hook's handleSaveChanges */}
|
||||||
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
|
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Name Component */}
|
{/* Pass form control from hook */}
|
||||||
<EditConnectorNameForm control={editForm.control} />
|
<EditConnectorNameForm control={editForm.control} />
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
@ -376,14 +94,15 @@ export default function EditConnectorPage() { // Renamed for clarity
|
||||||
{/* == GitHub == */}
|
{/* == GitHub == */}
|
||||||
{connector.connector_type === 'GITHUB_CONNECTOR' && (
|
{connector.connector_type === 'GITHUB_CONNECTOR' && (
|
||||||
<EditGitHubConnectorConfig
|
<EditGitHubConnectorConfig
|
||||||
|
// Pass relevant state and handlers from hook
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
|
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||||
originalPat={originalPat}
|
originalPat={originalPat}
|
||||||
currentSelectedRepos={currentSelectedRepos}
|
currentSelectedRepos={currentSelectedRepos}
|
||||||
fetchedRepos={fetchedRepos}
|
fetchedRepos={fetchedRepos}
|
||||||
newSelectedRepos={newSelectedRepos}
|
newSelectedRepos={newSelectedRepos}
|
||||||
isFetchingRepos={isFetchingRepos}
|
isFetchingRepos={isFetchingRepos}
|
||||||
patForm={patForm}
|
patForm={patForm}
|
||||||
setEditMode={setEditMode}
|
|
||||||
handleFetchRepositories={handleFetchRepositories}
|
handleFetchRepositories={handleFetchRepositories}
|
||||||
handleRepoSelectionChange={handleRepoSelectionChange}
|
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||||
setNewSelectedRepos={setNewSelectedRepos}
|
setNewSelectedRepos={setNewSelectedRepos}
|
||||||
|
@ -401,7 +120,6 @@ export default function EditConnectorPage() { // Renamed for clarity
|
||||||
placeholder="Begins with xoxb-..."
|
placeholder="Begins with xoxb-..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* == Notion == */}
|
{/* == Notion == */}
|
||||||
{connector.connector_type === 'NOTION_CONNECTOR' && (
|
{connector.connector_type === 'NOTION_CONNECTOR' && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
|
@ -412,8 +130,7 @@ export default function EditConnectorPage() { // Renamed for clarity
|
||||||
placeholder="Begins with secret_..."
|
placeholder="Begins with secret_..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* == Serper == */}
|
||||||
{/* == Serper API == */}
|
|
||||||
{connector.connector_type === 'SERPER_API' && (
|
{connector.connector_type === 'SERPER_API' && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
|
@ -422,8 +139,7 @@ export default function EditConnectorPage() { // Renamed for clarity
|
||||||
fieldDescription="Update the Serper API Key if needed."
|
fieldDescription="Update the Serper API Key if needed."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* == Tavily == */}
|
||||||
{/* == Tavily API == */}
|
|
||||||
{connector.connector_type === 'TAVILY_API' && (
|
{connector.connector_type === 'TAVILY_API' && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
|
|
33
surfsense_web/components/editConnector/types.ts
Normal file
33
surfsense_web/components/editConnector/types.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface GithubRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
private: boolean;
|
||||||
|
url: string;
|
||||||
|
description: string | null;
|
||||||
|
last_updated: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditMode = 'viewing' | 'editing_repos';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
export const githubPatSchema = z.object({
|
||||||
|
github_pat: z.string()
|
||||||
|
.min(20, { message: "GitHub Personal Access Token seems too short." })
|
||||||
|
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), {
|
||||||
|
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
export type GithubPatFormValues = z.infer<typeof githubPatSchema>;
|
||||||
|
|
||||||
|
export const editConnectorSchema = z.object({
|
||||||
|
name: z.string().min(3, { message: "Connector name must be at least 3 characters." }),
|
||||||
|
SLACK_BOT_TOKEN: z.string().optional(),
|
||||||
|
NOTION_INTEGRATION_TOKEN: z.string().optional(),
|
||||||
|
SERPER_API_KEY: z.string().optional(),
|
||||||
|
TAVILY_API_KEY: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;
|
210
surfsense_web/hooks/useConnectorEditPage.ts
Normal file
210
surfsense_web/hooks/useConnectorEditPage.ts
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useSearchSourceConnectors, SearchSourceConnector } from '@/hooks/useSearchSourceConnectors';
|
||||||
|
import {
|
||||||
|
GithubRepo,
|
||||||
|
EditMode,
|
||||||
|
githubPatSchema,
|
||||||
|
editConnectorSchema,
|
||||||
|
GithubPatFormValues,
|
||||||
|
EditConnectorFormValues
|
||||||
|
} from '@/components/editConnector/types';
|
||||||
|
|
||||||
|
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { connectors, updateConnector, isLoading: connectorsLoading } = useSearchSourceConnectors();
|
||||||
|
|
||||||
|
// State managed by the hook
|
||||||
|
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||||
|
const [originalConfig, setOriginalConfig] = useState<Record<string, any> | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [currentSelectedRepos, setCurrentSelectedRepos] = useState<string[]>([]);
|
||||||
|
const [originalPat, setOriginalPat] = useState<string>("");
|
||||||
|
const [editMode, setEditMode] = useState<EditMode>('viewing');
|
||||||
|
const [fetchedRepos, setFetchedRepos] = useState<GithubRepo[] | null>(null);
|
||||||
|
const [newSelectedRepos, setNewSelectedRepos] = useState<string[]>([]);
|
||||||
|
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||||
|
|
||||||
|
// Forms managed by the hook
|
||||||
|
const patForm = useForm<GithubPatFormValues>({
|
||||||
|
resolver: zodResolver(githubPatSchema),
|
||||||
|
defaultValues: { github_pat: "" },
|
||||||
|
});
|
||||||
|
const editForm = useForm<EditConnectorFormValues>({
|
||||||
|
resolver: zodResolver(editConnectorSchema),
|
||||||
|
defaultValues: { name: "", SLACK_BOT_TOKEN: "", NOTION_INTEGRATION_TOKEN: "", SERPER_API_KEY: "", TAVILY_API_KEY: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect to load initial data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connectorsLoading && connectors.length > 0 && !connector) {
|
||||||
|
const currentConnector = connectors.find(c => c.id === connectorId);
|
||||||
|
if (currentConnector) {
|
||||||
|
setConnector(currentConnector);
|
||||||
|
const config = currentConnector.config || {};
|
||||||
|
setOriginalConfig(config);
|
||||||
|
editForm.reset({
|
||||||
|
name: currentConnector.name,
|
||||||
|
SLACK_BOT_TOKEN: config.SLACK_BOT_TOKEN || "",
|
||||||
|
NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "",
|
||||||
|
SERPER_API_KEY: config.SERPER_API_KEY || "",
|
||||||
|
TAVILY_API_KEY: config.TAVILY_API_KEY || "",
|
||||||
|
});
|
||||||
|
if (currentConnector.connector_type === 'GITHUB_CONNECTOR') {
|
||||||
|
const savedRepos = config.repo_full_names || [];
|
||||||
|
const savedPat = config.GITHUB_PAT || "";
|
||||||
|
setCurrentSelectedRepos(savedRepos);
|
||||||
|
setNewSelectedRepos(savedRepos);
|
||||||
|
setOriginalPat(savedPat);
|
||||||
|
patForm.reset({ github_pat: savedPat });
|
||||||
|
setEditMode('viewing');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error("Connector not found.");
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [connectorId, connectors, connectorsLoading, router, searchSpaceId, connector, editForm, patForm]);
|
||||||
|
|
||||||
|
// Handlers managed by the hook
|
||||||
|
const handleFetchRepositories = useCallback(async (values: GithubPatFormValues) => {
|
||||||
|
setIsFetchingRepos(true);
|
||||||
|
setFetchedRepos(null);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('surfsense_bearer_token');
|
||||||
|
if (!token) throw new Error('No auth token');
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ github_pat: values.github_pat }) }
|
||||||
|
);
|
||||||
|
if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'Fetch failed'); }
|
||||||
|
const data: GithubRepo[] = await response.json();
|
||||||
|
setFetchedRepos(data);
|
||||||
|
setNewSelectedRepos(currentSelectedRepos);
|
||||||
|
toast.success(`Found ${data.length} repos.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching GitHub repositories:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to fetch repositories.");
|
||||||
|
} finally { setIsFetchingRepos(false); }
|
||||||
|
}, [currentSelectedRepos]); // Added dependency
|
||||||
|
|
||||||
|
const handleRepoSelectionChange = useCallback((repoFullName: string, checked: boolean) => {
|
||||||
|
setNewSelectedRepos(prev => checked ? [...prev, repoFullName] : prev.filter(name => name !== repoFullName));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveChanges = useCallback(async (formData: EditConnectorFormValues) => {
|
||||||
|
if (!connector || !originalConfig) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
const updatePayload: Partial<SearchSourceConnector> = {};
|
||||||
|
let configChanged = false;
|
||||||
|
let newConfig: Record<string, any> | null = null;
|
||||||
|
|
||||||
|
if (formData.name !== connector.name) {
|
||||||
|
updatePayload.name = formData.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (connector.connector_type) {
|
||||||
|
case 'GITHUB_CONNECTOR':
|
||||||
|
const currentPatInForm = patForm.getValues('github_pat');
|
||||||
|
const patChanged = currentPatInForm !== originalPat;
|
||||||
|
const initialRepoSet = new Set(currentSelectedRepos);
|
||||||
|
const newRepoSet = new Set(newSelectedRepos);
|
||||||
|
const reposChanged = initialRepoSet.size !== newRepoSet.size || ![...initialRepoSet].every(repo => newRepoSet.has(repo));
|
||||||
|
if (patChanged || (editMode === 'editing_repos' && reposChanged && fetchedRepos !== null)) {
|
||||||
|
if (!currentPatInForm || !(currentPatInForm.startsWith('ghp_') || currentPatInForm.startsWith('github_pat_'))) {
|
||||||
|
toast.error("Invalid GitHub PAT format. Cannot save."); setIsSaving(false); return;
|
||||||
|
}
|
||||||
|
newConfig = { GITHUB_PAT: currentPatInForm, repo_full_names: newSelectedRepos };
|
||||||
|
if (reposChanged && newSelectedRepos.length === 0) { toast.warning("Warning: No repositories selected."); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'SLACK_CONNECTOR':
|
||||||
|
if (formData.SLACK_BOT_TOKEN !== originalConfig.SLACK_BOT_TOKEN) {
|
||||||
|
if (!formData.SLACK_BOT_TOKEN) { toast.error("Slack Token empty."); setIsSaving(false); return; }
|
||||||
|
newConfig = { SLACK_BOT_TOKEN: formData.SLACK_BOT_TOKEN };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// ... other cases ...
|
||||||
|
case 'NOTION_CONNECTOR':
|
||||||
|
if (formData.NOTION_INTEGRATION_TOKEN !== originalConfig.NOTION_INTEGRATION_TOKEN) {
|
||||||
|
if (!formData.NOTION_INTEGRATION_TOKEN) { toast.error("Notion Token empty."); setIsSaving(false); return; }
|
||||||
|
newConfig = { NOTION_INTEGRATION_TOKEN: formData.NOTION_INTEGRATION_TOKEN };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'SERPER_API':
|
||||||
|
if (formData.SERPER_API_KEY !== originalConfig.SERPER_API_KEY) {
|
||||||
|
if (!formData.SERPER_API_KEY) { toast.error("Serper Key empty."); setIsSaving(false); return; }
|
||||||
|
newConfig = { SERPER_API_KEY: formData.SERPER_API_KEY };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'TAVILY_API':
|
||||||
|
if (formData.TAVILY_API_KEY !== originalConfig.TAVILY_API_KEY) {
|
||||||
|
if (!formData.TAVILY_API_KEY) { toast.error("Tavily Key empty."); setIsSaving(false); return; }
|
||||||
|
newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newConfig !== null) {
|
||||||
|
updatePayload.config = newConfig;
|
||||||
|
configChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updatePayload).length === 0) {
|
||||||
|
toast.info("No changes detected.");
|
||||||
|
setIsSaving(false);
|
||||||
|
if (connector.connector_type === 'GITHUB_CONNECTOR') { setEditMode('viewing'); patForm.reset({ github_pat: originalPat }); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateConnector(connectorId, updatePayload);
|
||||||
|
toast.success("Connector updated!");
|
||||||
|
const newlySavedConfig = updatePayload.config || originalConfig;
|
||||||
|
setOriginalConfig(newlySavedConfig);
|
||||||
|
if (updatePayload.name) {
|
||||||
|
setConnector(prev => prev ? { ...prev, name: updatePayload.name!, config: newlySavedConfig } : null);
|
||||||
|
}
|
||||||
|
if (connector.connector_type === 'GITHUB_CONNECTOR' && configChanged) {
|
||||||
|
const savedGitHubConfig = newlySavedConfig as { GITHUB_PAT?: string; repo_full_names?: string[] };
|
||||||
|
setCurrentSelectedRepos(savedGitHubConfig.repo_full_names || []);
|
||||||
|
setOriginalPat(savedGitHubConfig.GITHUB_PAT || "");
|
||||||
|
setNewSelectedRepos(savedGitHubConfig.repo_full_names || []);
|
||||||
|
patForm.reset({ github_pat: savedGitHubConfig.GITHUB_PAT || "" });
|
||||||
|
}
|
||||||
|
if (connector.connector_type === 'GITHUB_CONNECTOR') {
|
||||||
|
setEditMode('viewing');
|
||||||
|
setFetchedRepos(null);
|
||||||
|
}
|
||||||
|
// Resetting simple form values is handled by useEffect if connector state updates
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating connector:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to update connector.");
|
||||||
|
} finally { setIsSaving(false); }
|
||||||
|
}, [connector, originalConfig, updateConnector, connectorId, patForm, originalPat, currentSelectedRepos, newSelectedRepos, editMode, fetchedRepos]); // Added dependencies
|
||||||
|
|
||||||
|
// Return values needed by the component
|
||||||
|
return {
|
||||||
|
connectorsLoading,
|
||||||
|
connector,
|
||||||
|
isSaving,
|
||||||
|
editForm,
|
||||||
|
patForm,
|
||||||
|
handleSaveChanges,
|
||||||
|
// GitHub specific props
|
||||||
|
editMode,
|
||||||
|
setEditMode,
|
||||||
|
originalPat,
|
||||||
|
currentSelectedRepos,
|
||||||
|
fetchedRepos,
|
||||||
|
setFetchedRepos,
|
||||||
|
newSelectedRepos,
|
||||||
|
setNewSelectedRepos,
|
||||||
|
isFetchingRepos,
|
||||||
|
handleFetchRepositories,
|
||||||
|
handleRepoSelectionChange,
|
||||||
|
};
|
||||||
|
}
|
11
surfsense_web/lib/connectors/utils.ts
Normal file
11
surfsense_web/lib/connectors/utils.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// Helper function to get connector type display name
|
||||||
|
export const getConnectorTypeDisplay = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
"SERPER_API": "Serper API",
|
||||||
|
"TAVILY_API": "Tavily API",
|
||||||
|
"SLACK_CONNECTOR": "Slack",
|
||||||
|
"NOTION_CONNECTOR": "Notion",
|
||||||
|
"GITHUB_CONNECTOR": "GitHub",
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue