diff --git a/app/api/integrations/route.ts b/app/api/integrations/route.ts index f159dd80..6d81d56c 100644 --- a/app/api/integrations/route.ts +++ b/app/api/integrations/route.ts @@ -16,7 +16,7 @@ export type GetIntegrationsResponse = { }[]; export type CreateIntegrationRequest = { - name: string; + name?: string; type: IntegrationType; config: IntegrationConfig; }; @@ -92,16 +92,16 @@ export async function POST(request: Request) { const body: CreateIntegrationRequest = await request.json(); - if (!(body.name && body.type && body.config)) { + if (!(body.type && body.config)) { return NextResponse.json( - { error: "Name, type, and config are required" }, + { error: "Type and config are required" }, { status: 400 } ); } const integration = await createIntegration( session.user.id, - body.name, + body.name || "", body.type, body.config ); diff --git a/app/api/integrations/test/route.ts b/app/api/integrations/test/route.ts new file mode 100644 index 00000000..fc7838e5 --- /dev/null +++ b/app/api/integrations/test/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server"; +import postgres from "postgres"; +import { auth } from "@/lib/auth"; +import type { + IntegrationConfig, + IntegrationType, +} from "@/lib/types/integration"; +import { + getCredentialMapping, + getIntegration as getPluginFromRegistry, +} from "@/plugins"; + +export type TestConnectionRequest = { + type: IntegrationType; + config: IntegrationConfig; +}; + +export type TestConnectionResult = { + status: "success" | "error"; + message: string; +}; + +/** + * POST /api/integrations/test + * Test connection credentials without saving + */ +export async function POST(request: Request) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body: TestConnectionRequest = await request.json(); + + if (!(body.type && body.config)) { + return NextResponse.json( + { error: "Type and config are required" }, + { status: 400 } + ); + } + + if (body.type === "database") { + const result = await testDatabaseConnection(body.config.url); + return NextResponse.json(result); + } + + const plugin = getPluginFromRegistry(body.type); + + if (!plugin) { + return NextResponse.json( + { error: "Invalid integration type" }, + { status: 400 } + ); + } + + if (!plugin.testConfig) { + return NextResponse.json( + { error: "Integration does not support testing" }, + { status: 400 } + ); + } + + const credentials = getCredentialMapping(plugin, body.config); + + const testFn = await plugin.testConfig.getTestFunction(); + const testResult = await testFn(credentials); + + const result: TestConnectionResult = { + status: testResult.success ? "success" : "error", + message: testResult.success + ? "Connection successful" + : testResult.error || "Connection failed", + }; + + return NextResponse.json(result); + } catch (error) { + console.error("Failed to test connection:", error); + return NextResponse.json( + { + status: "error", + message: + error instanceof Error ? error.message : "Failed to test connection", + }, + { status: 500 } + ); + } +} + +async function testDatabaseConnection( + databaseUrl?: string +): Promise { + let connection: postgres.Sql | null = null; + + try { + if (!databaseUrl) { + return { + status: "error", + message: "Connection failed", + }; + } + + connection = postgres(databaseUrl, { + max: 1, + idle_timeout: 5, + connect_timeout: 5, + }); + + await connection`SELECT 1`; + + return { + status: "success", + message: "Connection successful", + }; + } catch { + return { + status: "error", + message: "Connection failed", + }; + } finally { + if (connection) { + await connection.end(); + } + } +} diff --git a/components/settings/api-keys-dialog.tsx b/components/settings/api-keys-dialog.tsx index d3633963..f40053b5 100644 --- a/components/settings/api-keys-dialog.tsx +++ b/components/settings/api-keys-dialog.tsx @@ -253,25 +253,38 @@ export function ApiKeysDialog({ open, onOpenChange }: ApiKeysDialogProps) { Create a new API key for webhook authentication -
-
- - setNewKeyName(e.target.value)} - placeholder="e.g., Production, Testing" - value={newKeyName} - /> +
{ + e.preventDefault(); + handleCreate(); + }} + > +
+
+ + setNewKeyName(e.target.value)} + placeholder="e.g., Production, Testing" + value={newKeyName} + /> +
-
+ - diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 5e411465..df9d9d31 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -83,21 +83,36 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
) : ( -
+
{ + e.preventDefault(); + saveAccount(); + }} + > -
+ )} - - diff --git a/components/settings/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index 14be45dd..5cf37e18 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -1,8 +1,28 @@ "use client"; -import { ArrowLeft } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + ArrowLeft, + Check, + CheckCircle2, + Pencil, + Search, + Trash2, + X, + XCircle, + Zap, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -18,7 +38,6 @@ import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { api, type Integration } from "@/lib/api-client"; import type { IntegrationType } from "@/lib/types/integration"; -import { cn } from "@/lib/utils"; import { getIntegration, getIntegrationLabels, @@ -29,6 +48,7 @@ type IntegrationFormDialogProps = { open: boolean; onClose: () => void; onSuccess?: (integrationId: string) => void; + onDelete?: () => void; integration?: Integration | null; mode: "create" | "edit"; preselectedType?: IntegrationType; @@ -56,27 +76,400 @@ const getIntegrationTypes = (): IntegrationType[] => [ const getLabel = (type: IntegrationType): string => getIntegrationLabels()[type] || SYSTEM_INTEGRATION_LABELS[type] || type; +function SecretField({ + fieldId, + label, + configKey, + placeholder, + helpText, + helpLink, + value, + onChange, + isEditMode, +}: { + fieldId: string; + label: string; + configKey: string; + placeholder?: string; + helpText?: string; + helpLink?: { url: string; text: string }; + value: string; + onChange: (key: string, value: string) => void; + isEditMode: boolean; +}) { + const [isEditing, setIsEditing] = useState(!isEditMode); + const hasNewValue = value.length > 0; + + // In edit mode, start with "configured" state + // User can click to change, or clear after entering a new value + if (isEditMode && !isEditing && !hasNewValue) { + return ( +
+ +
+
+ + Configured +
+ +
+
+ ); + } + + return ( +
+ +
+ onChange(configKey, e.target.value)} + placeholder={placeholder} + type="password" + value={value} + /> + {isEditMode && (isEditing || hasNewValue) && ( + + )} +
+ {(helpText || helpLink) && ( +

+ {helpText} + {helpLink && ( + + {helpLink.text} + + )} +

+ )} +
+ ); +} + +function ConfigFields({ + formData, + updateConfig, + isEditMode, +}: { + formData: IntegrationFormData; + updateConfig: (key: string, value: string) => void; + isEditMode: boolean; +}) { + if (!formData.type) { + return null; + } + + // Handle system integrations with hardcoded fields + if (formData.type === "database") { + return ( + + ); + } + + // Get plugin form fields from registry + const plugin = getIntegration(formData.type); + if (!plugin?.formFields) { + return null; + } + + return plugin.formFields.map((field) => { + const isSecretField = field.type === "password"; + + if (isSecretField) { + return ( + + ); + } + + return ( +
+ + updateConfig(field.configKey, e.target.value)} + placeholder={field.placeholder} + type={field.type} + value={formData.config[field.configKey] || ""} + /> + {(field.helpText || field.helpLink) && ( +

+ {field.helpText} + {field.helpLink && ( + + {field.helpLink.text} + + )} +

+ )} +
+ ); + }); +} + +function FormFooterActions({ + step, + mode, + preselectedType, + saving, + deleting, + testing, + testResult, + onBack, + onDelete, + onTestConnection, + onClose, +}: { + step: "select" | "configure"; + mode: "create" | "edit"; + preselectedType?: IntegrationType; + saving: boolean; + deleting: boolean; + testing: boolean; + testResult: { status: "success" | "error"; message: string } | null; + onBack: () => void; + onDelete: () => void; + onTestConnection: () => void; + onClose: () => void; +}) { + if (step === "select") { + return ( + + ); + } + + return ( + <> +
+ {mode === "create" && !preselectedType && ( + + )} + {mode === "edit" && ( + + )} + +
+
+ + +
+ + ); +} + +function TestConnectionIcon({ + testing, + testResult, +}: { + testing: boolean; + testResult: { status: "success" | "error"; message: string } | null; +}) { + if (testing) { + return ; + } + if (testResult?.status === "success") { + return ; + } + if (testResult?.status === "error") { + return ; + } + return ; +} + +function DeleteConfirmDialog({ + open, + onOpenChange, + deleting, + onDelete, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + deleting: boolean; + onDelete: () => void; +}) { + return ( + + + + Delete Connection + + Are you sure you want to delete this connection? Workflows using it + will fail until a new one is configured. + + + + Cancel + + {deleting ? : null} + Delete + + + + + ); +} + +function TypeSelector({ + searchQuery, + onSearchChange, + filteredTypes, + onSelectType, +}: { + searchQuery: string; + onSearchChange: (value: string) => void; + filteredTypes: IntegrationType[]; + onSelectType: (type: IntegrationType) => void; +}) { + return ( +
+
+ + onSearchChange(e.target.value)} + placeholder="Search services..." + value={searchQuery} + /> +
+
+ {filteredTypes.length === 0 ? ( +

+ No services found +

+ ) : ( + filteredTypes.map((type) => ( + + )) + )} +
+
+ ); +} + export function IntegrationFormDialog({ open, onClose, onSuccess, + onDelete, integration, mode, preselectedType, }: IntegrationFormDialogProps) { const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ + status: "success" | "error"; + message: string; + } | null>(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const [formData, setFormData] = useState({ name: "", type: preselectedType || null, config: {}, }); - // Step: "select" for type selection grid, "configure" for form + // Step: "select" for type selection list, "configure" for form const [step, setStep] = useState<"select" | "configure">( preselectedType || mode === "edit" ? "configure" : "select" ); useEffect(() => { + setTestResult(null); if (integration) { setFormData({ name: integration.name, @@ -105,6 +498,7 @@ export function IntegrationFormDialog({ const handleBack = () => { setStep("select"); + setSearchQuery(""); setFormData({ name: "", type: null, @@ -120,16 +514,18 @@ export function IntegrationFormDialog({ try { setSaving(true); - // Generate a default name if none provided - const integrationName = - formData.name.trim() || `${getLabel(formData.type)} Integration`; + const integrationName = formData.name.trim(); if (mode === "edit" && integration) { + // Only include config if there are actual new values entered + const hasNewConfig = Object.values(formData.config).some( + (v) => v && v.length > 0 + ); await api.integration.update(integration.id, { name: integrationName, - config: formData.config, + ...(hasNewConfig ? { config: formData.config } : {}), }); - toast.success("Integration updated"); + toast.success("Connection updated"); onSuccess?.(integration.id); } else { const newIntegration = await api.integration.create({ @@ -148,178 +544,176 @@ export function IntegrationFormDialog({ } }; - const updateConfig = (key: string, value: string) => { - setFormData({ - ...formData, - config: { ...formData.config, [key]: value }, - }); + const handleDelete = async () => { + if (!integration) { + return; + } + + try { + setDeleting(true); + await api.integration.delete(integration.id); + toast.success("Connection deleted"); + onDelete?.(); + onClose(); + } catch (error) { + console.error("Failed to delete integration:", error); + toast.error("Failed to delete connection"); + } finally { + setDeleting(false); + setShowDeleteConfirm(false); + } }; - const renderConfigFields = () => { + const handleTestConnection = async () => { if (!formData.type) { - return null; + return; } - // Handle system integrations with hardcoded fields - if (formData.type === "database") { - return ( -
- - updateConfig("url", e.target.value)} - placeholder="postgresql://..." - type="password" - value={formData.config.url || ""} - /> -

- Connection string in the format: - postgresql://user:password@host:port/database -

-
- ); + // Check if we have any config values to test + const hasConfig = Object.values(formData.config).some( + (v) => v && v.length > 0 + ); + if (!hasConfig && mode === "create") { + toast.error("Please enter credentials first"); + return; } - // Get plugin form fields from registry - const plugin = getIntegration(formData.type); - if (!plugin?.formFields) { - return null; + try { + setTesting(true); + setTestResult(null); + + let result: { status: "success" | "error"; message: string }; + + if (mode === "edit" && integration && !hasConfig) { + // Test existing integration (no new config entered) + result = await api.integration.testConnection(integration.id); + } else { + // Test with new credentials + result = await api.integration.testCredentials({ + type: formData.type, + config: formData.config, + }); + } + + setTestResult(result); + } catch (error) { + console.error("Failed to test connection:", error); + const message = + error instanceof Error ? error.message : "Failed to test connection"; + setTestResult({ status: "error", message }); + } finally { + setTesting(false); } + }; - return plugin.formFields.map((field) => ( -
- - updateConfig(field.configKey, e.target.value)} - placeholder={field.placeholder} - type={field.type} - value={formData.config[field.configKey] || ""} - /> - {(field.helpText || field.helpLink) && ( -

- {field.helpText} - {field.helpLink && ( - - {field.helpLink.text} - - )} -

- )} -
- )); + const updateConfig = (key: string, value: string) => { + setFormData({ + ...formData, + config: { ...formData.config, [key]: value }, + }); }; const integrationTypes = getIntegrationTypes(); + const filteredIntegrationTypes = useMemo(() => { + if (!searchQuery.trim()) { + return integrationTypes; + } + const query = searchQuery.toLowerCase(); + return integrationTypes.filter((type) => + getLabel(type).toLowerCase().includes(query) + ); + }, [integrationTypes, searchQuery]); + const getDialogTitle = () => { if (mode === "edit") { - return "Edit Integration"; + return "Edit Connection"; } if (step === "select") { - return "Choose Integration"; + return "Add Connection"; } - return `Add ${formData.type ? getLabel(formData.type) : ""} Integration`; + return `Add ${formData.type ? getLabel(formData.type) : ""} Connection`; }; const getDialogDescription = () => { if (mode === "edit") { - return "Update integration configuration"; + return "Update your connection credentials"; } if (step === "select") { - return "Select an integration type to configure"; + return "Select a service to connect"; } - return "Configure your integration"; + return "Enter your credentials"; }; return ( !isOpen && onClose()} open={open}> - + {getDialogTitle()} {getDialogDescription()} {step === "select" ? ( -
- {integrationTypes.map((type) => ( - - ))} -
+ ) : ( -
- {renderConfigFields()} +
{ + e.preventDefault(); + handleSave(); + }} + > +
- + setFormData({ ...formData, name: e.target.value }) } - placeholder={ - formData.type - ? `${getLabel(formData.type)} Integration` - : "Integration" - } + placeholder="e.g. Production, Personal, Work" value={formData.name} />
-
+ )} - {step === "configure" && mode === "create" && !preselectedType && ( - - )} - {step === "select" ? ( - - ) : ( -
- - -
- )} + setShowDeleteConfirm(true)} + onTestConnection={handleTestConnection} + preselectedType={preselectedType} + saving={saving} + step={step} + testing={testing} + testResult={testResult} + />
+ +
); } diff --git a/components/settings/integrations-dialog.tsx b/components/settings/integrations-dialog.tsx index 443a080a..f992f4f7 100644 --- a/components/settings/integrations-dialog.tsx +++ b/components/settings/integrations-dialog.tsx @@ -73,9 +73,9 @@ export function IntegrationsDialog({ showCloseButton={false} > - Integrations + Connections - Manage your integrations that can be used across workflows + Manage API keys and credentials used by your workflows @@ -86,16 +86,17 @@ export function IntegrationsDialog({ ) : (
setShowCreateDialog(false)} onIntegrationChange={handleIntegrationChange} showCreateDialog={showCreateDialog} />
)} - + diff --git a/components/settings/integrations-manager.tsx b/components/settings/integrations-manager.tsx index 821e623b..39510676 100644 --- a/components/settings/integrations-manager.tsx +++ b/components/settings/integrations-manager.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronRight, Pencil, Trash2 } from "lucide-react"; +import { Pencil, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -14,15 +14,9 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { IntegrationIcon } from "@/components/ui/integration-icon"; import { Spinner } from "@/components/ui/spinner"; import { api, type Integration } from "@/lib/api-client"; -import { cn } from "@/lib/utils"; import { getIntegrationLabels } from "@/plugins"; import { IntegrationFormDialog } from "./integration-form-dialog"; @@ -33,11 +27,13 @@ const SYSTEM_INTEGRATION_LABELS: Record = { type IntegrationsManagerProps = { showCreateDialog: boolean; + onCreateDialogClose?: () => void; onIntegrationChange?: () => void; }; export function IntegrationsManager({ showCreateDialog: externalShowCreateDialog, + onCreateDialogClose, onIntegrationChange, }: IntegrationsManagerProps) { const [integrations, setIntegrations] = useState([]); @@ -47,7 +43,6 @@ export function IntegrationsManager({ const [showCreateDialog, setShowCreateDialog] = useState(false); const [deletingId, setDeletingId] = useState(null); const [testingId, setTestingId] = useState(null); - const [expandedGroups, setExpandedGroups] = useState>(new Set()); // Sync external dialog state useEffect(() => { @@ -71,27 +66,25 @@ export function IntegrationsManager({ loadIntegrations(); }, [loadIntegrations]); - // Group integrations by type - const groupedIntegrations = useMemo(() => { - const groups = new Map(); + // Get integrations with their labels, sorted by label then name + const integrationsWithLabels = useMemo(() => { const labels = getIntegrationLabels() as Record; - for (const integration of integrations) { - const type = integration.type; - if (!groups.has(type)) { - groups.set(type, []); - } - groups.get(type)?.push(integration); - } - - // Sort groups by label - return Array.from(groups.entries()) - .map(([type, items]) => ({ - type, - label: labels[type] || SYSTEM_INTEGRATION_LABELS[type] || type, - items, + return integrations + .map((integration) => ({ + ...integration, + label: + labels[integration.type] || + SYSTEM_INTEGRATION_LABELS[integration.type] || + integration.type, })) - .sort((a, b) => a.label.localeCompare(b.label)); + .sort((a, b) => { + const labelCompare = a.label.localeCompare(b.label); + if (labelCompare !== 0) { + return labelCompare; + } + return a.name.localeCompare(b.name); + }); }, [integrations]); const handleDelete = async (id: string) => { @@ -130,6 +123,7 @@ export function IntegrationsManager({ const handleDialogClose = () => { setShowCreateDialog(false); setEditingIntegration(null); + onCreateDialogClose?.(); }; const handleDialogSuccess = async () => { @@ -137,18 +131,6 @@ export function IntegrationsManager({ onIntegrationChange?.(); }; - const toggleGroup = (type: string) => { - setExpandedGroups((prev) => { - const next = new Set(prev); - if (next.has(type)) { - next.delete(type); - } else { - next.add(type); - } - return next; - }); - }; - if (loading) { return (
@@ -162,76 +144,62 @@ export function IntegrationsManager({ {integrations.length === 0 ? (

- No integrations configured yet + No connections configured yet

) : (
- {groupedIntegrations.map((group) => ( - toggleGroup(group.type)} - open={expandedGroups.has(group.type)} + {integrationsWithLabels.map((integration) => ( +
- +
- {group.label} - {integration.label} + + {integration.name} + +
+
+ - - -
-
- ))} -
- - + + + +
+ ))} )} @@ -256,10 +224,10 @@ export function IntegrationsManager({ > - Delete Integration + Delete Connection - Are you sure you want to delete this integration? Workflows using - this integration will fail until a new one is selected. + Are you sure you want to delete this connection? Workflows using + it will fail until a new one is configured. diff --git a/components/ui/integration-selector.tsx b/components/ui/integration-selector.tsx index 4d5323ed..eb5c954e 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -1,22 +1,17 @@ "use client"; -import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle } from "lucide-react"; -import { useEffect, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { AlertTriangle, Check, Circle, Pencil, Plus, Settings } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; import { api, type Integration } from "@/lib/api-client"; import { integrationsAtom, integrationsVersionAtom, } from "@/lib/integrations-store"; import type { IntegrationType } from "@/lib/types/integration"; +import { cn } from "@/lib/utils"; +import { getIntegration } from "@/plugins"; import { IntegrationFormDialog } from "@/components/settings/integration-form-dialog"; type IntegrationSelectorProps = { @@ -24,8 +19,8 @@ type IntegrationSelectorProps = { value?: string; onChange: (integrationId: string) => void; onOpenSettings?: () => void; - label?: string; disabled?: boolean; + onAddConnection?: () => void; }; export function IntegrationSelector({ @@ -33,87 +28,113 @@ export function IntegrationSelector({ value, onChange, onOpenSettings, - label, disabled, + onAddConnection, }: IntegrationSelectorProps) { - const [integrations, setIntegrations] = useState([]); - const [loading, setLoading] = useState(true); const [showNewDialog, setShowNewDialog] = useState(false); + const [editingIntegration, setEditingIntegration] = + useState(null); + const [globalIntegrations, setGlobalIntegrations] = useAtom(integrationsAtom); const integrationsVersion = useAtomValue(integrationsVersionAtom); - const setGlobalIntegrations = useSetAtom(integrationsAtom); const setIntegrationsVersion = useSetAtom(integrationsVersionAtom); + const lastVersionRef = useRef(integrationsVersion); + const [hasFetched, setHasFetched] = useState(false); + + // Filter integrations from global cache + const integrations = useMemo( + () => globalIntegrations.filter((i) => i.type === integrationType), + [globalIntegrations, integrationType] + ); - const loadIntegrations = async () => { + // Check if we have cached data + const hasCachedData = globalIntegrations.length > 0; + + const loadIntegrations = useCallback(async () => { try { - setLoading(true); const all = await api.integration.getAll(); // Update global store so other components can access it setGlobalIntegrations(all); - const filtered = all.filter((i) => i.type === integrationType); - setIntegrations(filtered); - - // Auto-select if only one option and nothing selected yet - if (filtered.length === 1 && !value) { - onChange(filtered[0].id); - } + setHasFetched(true); } catch (error) { console.error("Failed to load integrations:", error); - } finally { - setLoading(false); } - }; + }, [setGlobalIntegrations]); useEffect(() => { loadIntegrations(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [integrationType, integrationsVersion]); + }, [loadIntegrations, integrationType]); - const handleValueChange = (newValue: string) => { - if (newValue === "__new__") { - setShowNewDialog(true); - } else if (newValue === "__manage__") { - onOpenSettings?.(); - } else { - onChange(newValue); + // Listen for version changes (from other components creating/editing integrations) + useEffect(() => { + // Skip initial render - only react to actual version changes + if (integrationsVersion !== lastVersionRef.current) { + lastVersionRef.current = integrationsVersion; + loadIntegrations(); } - }; + }, [integrationsVersion, loadIntegrations]); + + // Auto-select single integration from cached data + useEffect(() => { + if (integrations.length === 1 && !value && !disabled) { + onChange(integrations[0].id); + } + }, [integrations, value, disabled, onChange]); const handleNewIntegrationCreated = async (integrationId: string) => { await loadIntegrations(); onChange(integrationId); setShowNewDialog(false); - // Increment version to trigger auto-fix for other nodes that need this integration type + // Increment version to trigger re-fetch in other selectors setIntegrationsVersion((v) => v + 1); }; - if (loading) { + const handleEditSuccess = async () => { + await loadIntegrations(); + setEditingIntegration(null); + setIntegrationsVersion((v) => v + 1); + }; + + const handleAddConnection = () => { + if (onAddConnection) { + onAddConnection(); + } else { + setShowNewDialog(true); + } + }; + + // Only show loading skeleton if we have no cached data and haven't fetched yet + if (!hasCachedData && !hasFetched) { return ( - +
+
+
+
+
+
+
); } + const plugin = getIntegration(integrationType); + const integrationLabel = plugin?.label || integrationType; + + // No integrations - show error button to add one if (integrations.length === 0) { return ( -
- - + <> + + setShowNewDialog(false)} @@ -121,29 +142,114 @@ export function IntegrationSelector({ open={showNewDialog} preselectedType={integrationType} /> -
+ ); } + // Single integration - show as outlined field (not radio-style) + if (integrations.length === 1) { + const integration = integrations[0]; + const displayName = integration.name || `${integrationLabel} API Key`; + + return ( + <> +
+ + {displayName} + +
+ + {editingIntegration && ( + setEditingIntegration(null)} + onDelete={async () => { + await loadIntegrations(); + setEditingIntegration(null); + setIntegrationsVersion((v) => v + 1); + }} + onSuccess={handleEditSuccess} + open + /> + )} + + ); + } + + // Multiple integrations - show radio-style selection list return ( -
- {label && {label}} - - + <> +
+ {integrations.map((integration) => { + const isSelected = value === integration.id; + const displayName = + integration.name || `${integrationLabel} API Key`; + return ( +
+ + +
+ ); + })} + {onOpenSettings && ( + + )} +
+ setShowNewDialog(false)} @@ -151,7 +257,22 @@ export function IntegrationSelector({ open={showNewDialog} preselectedType={integrationType} /> -
+ + {editingIntegration && ( + setEditingIntegration(null)} + onDelete={async () => { + await loadIntegrations(); + setEditingIntegration(null); + setIntegrationsVersion((v) => v + 1); + }} + onSuccess={handleEditSuccess} + open + /> + )} + ); } diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 00000000..a4e90d4e --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/components/workflow/config/action-config-renderer.tsx b/components/workflow/config/action-config-renderer.tsx index 030c20ba..7705763f 100644 --- a/components/workflow/config/action-config-renderer.tsx +++ b/components/workflow/config/action-config-renderer.tsx @@ -151,6 +151,7 @@ function renderField(
= [ const SYSTEM_ACTION_IDS = SYSTEM_ACTIONS.map((a) => a.id); +// System actions that need integrations (not in plugin registry) +const SYSTEM_ACTION_INTEGRATIONS: Record = { + "Database Query": "database", +}; + // Build category mapping dynamically from plugins + System function useCategoryData() { return useMemo(() => { @@ -303,12 +324,28 @@ export function ActionConfig({ // Get dynamic config fields for plugin actions const pluginAction = actionType ? findActionById(actionType) : null; + // Determine the integration type for the current action + const integrationType: IntegrationType | undefined = useMemo(() => { + if (!actionType) { + return; + } + + // Check system actions first + if (SYSTEM_ACTION_INTEGRATIONS[actionType]) { + return SYSTEM_ACTION_INTEGRATIONS[actionType]; + } + + // Check plugin actions + const action = findActionById(actionType); + return action?.integration as IntegrationType | undefined; + }, [actionType]); + return ( <>
setFilter(e.target.value)} - placeholder="Search actions..." - ref={inputRef} - value={filter} - /> -
+
+
+ + setFilter(e.target.value)} + placeholder="Search actions..." + ref={inputRef} + value={filter} + />
-
- {filteredActions.map((action) => ( - - ))} +
+ {filteredActions.length === 0 ? ( +

+ No actions found +

+ ) : ( + filteredActions.map((action) => ( + + )) + )}
- - {filteredActions.length === 0 && ( -

- No actions found -

- )}
); } diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 8942683e..c635b0bf 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -53,7 +53,6 @@ import { findActionById } from "@/plugins"; import { Panel } from "../ai-elements/panel"; import { IntegrationsDialog } from "../settings/integrations-dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; -import { IntegrationSelector } from "../ui/integration-selector"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { ActionConfig } from "./config/action-config"; import { ActionGrid } from "./config/action-grid"; @@ -775,19 +774,11 @@ export const PanelInner = () => { className="flex flex-col overflow-hidden" value="properties" > -
- {selectedNode.data.type === "trigger" && ( - - )} - - {selectedNode.data.type === "action" && - !selectedNode.data.config?.actionType && - isOwner && ( + {/* Action selection - full height flex layout */} + {selectedNode.data.type === "action" && + !selectedNode.data.config?.actionType && + isOwner && ( +
{ } }} /> +
+ )} + + {/* Other content - scrollable */} + {!( + selectedNode.data.type === "action" && + !selectedNode.data.config?.actionType && + isOwner + ) && ( +
+ {selectedNode.data.type === "trigger" && ( + )} - {selectedNode.data.type === "action" && - !selectedNode.data.config?.actionType && - !isOwner && ( + {selectedNode.data.type === "action" && + !selectedNode.data.config?.actionType && + !isOwner && ( +
+

+ No action configured for this step. +

+
+ )} + + {selectedNode.data.type === "action" && + selectedNode.data.config?.actionType ? ( + + ) : null} + + {selectedNode.data.type !== "action" || + selectedNode.data.config?.actionType ? ( + <> +
+ + handleUpdateLabel(e.target.value)} + value={selectedNode.data.label} + /> +
+ +
+ + handleUpdateDescription(e.target.value)} + placeholder="Optional description" + value={selectedNode.data.description || ""} + /> +
+ + ) : null} + + {!isOwner && (

- No action configured for this step. + You are viewing a public workflow. Duplicate it to make + changes.

)} - - {selectedNode.data.type === "action" && - selectedNode.data.config?.actionType ? ( - - ) : null} - - {selectedNode.data.type !== "action" || - selectedNode.data.config?.actionType ? ( - <> -
- - handleUpdateLabel(e.target.value)} - value={selectedNode.data.label} - /> -
- -
- - handleUpdateDescription(e.target.value)} - placeholder="Optional description" - value={selectedNode.data.description || ""} - /> -
- - ) : null} - - {!isOwner && ( -
-

- You are viewing a public workflow. Duplicate it to make - changes. -

-
- )} -
+
+ )} {selectedNode.data.type === "action" && isOwner && ( -
-
- - -
- - {(() => { - const actionType = selectedNode.data.config - ?.actionType as string; - - // Database Query is special - has integration but no plugin - const SYSTEM_INTEGRATION_MAP: Record = { - "Database Query": "database", - }; - - // Get integration type dynamically - let integrationType: string | undefined; - if (actionType) { - if (SYSTEM_INTEGRATION_MAP[actionType]) { - integrationType = SYSTEM_INTEGRATION_MAP[actionType]; - } else { - // Look up from plugin registry - const action = findActionById(actionType); - integrationType = action?.integration; - } +
+ +
)} {selectedNode.data.type === "trigger" && isOwner && ( diff --git a/components/workflow/workflow-toolbar.tsx b/components/workflow/workflow-toolbar.tsx index 227eefb6..6443a872 100644 --- a/components/workflow/workflow-toolbar.tsx +++ b/components/workflow/workflow-toolbar.tsx @@ -1573,10 +1573,20 @@ function WorkflowIssuesDialog({ const { brokenReferences, missingRequiredFields, missingIntegrations } = actions.workflowIssues; - const handleGoToStep = (nodeId: string) => { + const handleGoToStep = (nodeId: string, fieldKey?: string) => { actions.setShowWorkflowIssuesDialog(false); state.setSelectedNodeId(nodeId); state.setActiveTab("properties"); + // Focus on the specific field after a short delay to allow the panel to render + if (fieldKey) { + setTimeout(() => { + const element = document.getElementById(fieldKey); + if (element) { + element.focus(); + element.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, 100); + } }; const handleAddIntegration = (integrationType: IntegrationType) => { @@ -1608,147 +1618,128 @@ function WorkflowIssuesDialog({ -
- {/* Broken References Section */} - {brokenReferences.length > 0 && ( -
-

- - Broken References ({brokenReferences.length}) +
+ {/* Missing Connections Section */} + {missingIntegrations.length > 0 && ( +
+

+ Missing Connections

-
- {brokenReferences.map((broken) => ( -
( +
+ +

+ + {missing.integrationLabel} + + + {" — "} + {missing.nodeNames.length > 3 + ? `${missing.nodeNames.slice(0, 3).join(", ")} +${missing.nodeNames.length - 3} more` + : missing.nodeNames.join(", ")} + +

+ -
- ))} -
+ Add + +
+ ))}
)} - {/* Missing Required Fields Section */} - {missingRequiredFields.length > 0 && ( + {/* Broken References Section */} + {brokenReferences.length > 0 && (
-

- - Missing Required Fields ({missingRequiredFields.length}) +

+ Broken References

-
- {missingRequiredFields.map((node) => ( -
-
-

- {node.nodeLabel} -

-
- {node.missingFields.map((field) => ( -

- Missing:{" "} - - {field.fieldLabel} - -

- ))} + {brokenReferences.map((broken) => ( +
+

{broken.nodeLabel}

+
+ {broken.brokenReferences.map((ref, idx) => ( +
+

+ {ref.displayText} + {" in "} + {ref.fieldLabel} +

+
-
- + ))}
- ))} -
+
+ ))}
)} - {/* Missing Integrations Section */} - {missingIntegrations.length > 0 && ( + {/* Missing Required Fields Section */} + {missingRequiredFields.length > 0 && (
-

- - Missing Integrations ({missingIntegrations.length}) +

+ Missing Required Fields

-
- {missingIntegrations.map((missing) => ( -
- -
-

- {missing.integrationLabel} -

-

- Used by:{" "} - {missing.nodeNames.length > 3 - ? `${missing.nodeNames.slice(0, 3).join(", ")} and ${missing.nodeNames.length - 3} more` - : missing.nodeNames.join(", ")} -

-
- + {missingRequiredFields.map((node) => ( +
+

{node.nodeLabel}

+
+ {node.missingFields.map((field) => ( +
+

+ {field.fieldLabel} +

+ +
+ ))}
- ))} -
+
+ ))}
)}

- - Cancel + + Cancel @@ -1920,12 +1911,14 @@ function WorkflowDialogsComponent({ the workflow? - - Cancel + - +
+ Cancel + +
diff --git a/components/workflows/user-menu.tsx b/components/workflows/user-menu.tsx index 87f6af08..43013b13 100644 --- a/components/workflows/user-menu.tsx +++ b/components/workflows/user-menu.tsx @@ -141,7 +141,7 @@ export const UserMenu = () => { )} setIntegrationsOpen(true)}> - Integrations + Connections setApiKeysOpen(true)}> diff --git a/lib/api-client.ts b/lib/api-client.ts index a512d966..583e372c 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -362,7 +362,7 @@ export const integrationApi = { method: "DELETE", }), - // Test connection + // Test existing integration connection testConnection: (integrationId: string) => apiCall<{ status: "success" | "error"; message: string }>( `/api/integrations/${integrationId}/test`, @@ -370,6 +370,19 @@ export const integrationApi = { method: "POST", } ), + + // Test credentials without saving + testCredentials: (data: { + type: IntegrationType; + config: IntegrationConfig; + }) => + apiCall<{ status: "success" | "error"; message: string }>( + "/api/integrations/test", + { + method: "POST", + body: JSON.stringify(data), + } + ), }; // User API diff --git a/package.json b/package.json index 563163c4..73f30b35 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-tooltip": "^1.2.8", "@slack/web-api": "^7.12.0", "@vercel/analytics": "^1.5.0", "@vercel/og": "^0.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64aaecaa..a8662225 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@slack/web-api': specifier: ^7.12.0 version: 7.12.0