From 275e97b1eadabfdd6056b64f8df514e696dd6f46 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 11 Dec 2025 20:19:34 -0600 Subject: [PATCH 01/11] better connections dx --- app/api/integrations/route.ts | 8 +- .../settings/integration-form-dialog.tsx | 103 ++++---- components/settings/integrations-dialog.tsx | 6 +- components/settings/integrations-manager.tsx | 197 +++++++++------ components/ui/integration-selector.tsx | 126 +++++++--- components/ui/tooltip.tsx | 61 +++++ components/workflow/config/action-config.tsx | 58 ++++- components/workflow/config/action-grid.tsx | 88 ++++--- components/workflow/node-config-panel.tsx | 226 ++++++++---------- package.json | 1 + pnpm-lock.yaml | 3 + 11 files changed, 557 insertions(+), 320 deletions(-) create mode 100644 components/ui/tooltip.tsx diff --git a/app/api/integrations/route.ts b/app/api/integrations/route.ts index f159dd809..6d81d56c0 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/components/settings/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index 14be45ddf..aeae7939d 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import { ArrowLeft } from "lucide-react"; -import { useEffect, useState } from "react"; +import { ArrowLeft, Search } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -18,7 +18,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, @@ -65,13 +64,14 @@ export function IntegrationFormDialog({ preselectedType, }: IntegrationFormDialogProps) { const [saving, setSaving] = 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" ); @@ -105,6 +105,7 @@ export function IntegrationFormDialog({ const handleBack = () => { setStep("select"); + setSearchQuery(""); setFormData({ name: "", type: null, @@ -120,16 +121,14 @@ 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) { await api.integration.update(integration.id, { name: integrationName, config: formData.config, }); - toast.success("Integration updated"); + toast.success("Connection updated"); onSuccess?.(integration.id); } else { const newIntegration = await api.integration.create({ @@ -217,71 +216,91 @@ export function IntegrationFormDialog({ 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) => ( - - ))} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search services..." + value={searchQuery} + /> +
+
+ {filteredIntegrationTypes.length === 0 ? ( +

+ No services found +

+ ) : ( + filteredIntegrationTypes.map((type) => ( + + )) + )} +
) : (
{renderConfigFields()}
- + setFormData({ ...formData, name: e.target.value }) } - placeholder={ - formData.type - ? `${getLabel(formData.type)} Integration` - : "Integration" - } + placeholder="e.g. Production, Personal, Work" value={formData.name} />
@@ -289,9 +308,7 @@ export function IntegrationFormDialog({ )} {step === "configure" && mode === "create" && !preselectedType && ( diff --git a/components/settings/integrations-manager.tsx b/components/settings/integrations-manager.tsx index 821e623ba..82a9d8d38 100644 --- a/components/settings/integrations-manager.tsx +++ b/components/settings/integrations-manager.tsx @@ -162,77 +162,138 @@ 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)} - > - - - {group.label} - - - -
- {group.items.map((integration) => ( -
{ + // Single item - show flat entry without collapsible + if (group.items.length === 1) { + const integration = group.items[0]; + return ( +
+
+ + {group.label} + + {integration.name} + +
+
+ - - -
-
- ))} + {testingId === integration.id ? ( + + ) : ( + Test + )} + + + +
-
-
- ))} + ); + } + + // Multiple items - show collapsible group + return ( + toggleGroup(group.type)} + open={expandedGroups.has(group.type)} + > + + + {group.label} + + ({group.items.length}) + + + + +
+ {group.items.map((integration) => ( +
+ {integration.name} +
+ + + +
+
+ ))} +
+
+
+ ); + })}
)} @@ -256,10 +317,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 4d5323edf..0b3ecbf54 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -1,8 +1,22 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle } from "lucide-react"; +import { + AlertTriangle, + Check, + MoreHorizontal, + Plus, + Settings, + Trash2, +} from "lucide-react"; import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Select, SelectContent, @@ -17,6 +31,7 @@ import { integrationsVersionAtom, } from "@/lib/integrations-store"; import type { IntegrationType } from "@/lib/types/integration"; +import { getIntegration } from "@/plugins"; import { IntegrationFormDialog } from "@/components/settings/integration-form-dialog"; type IntegrationSelectorProps = { @@ -24,7 +39,6 @@ type IntegrationSelectorProps = { value?: string; onChange: (integrationId: string) => void; onOpenSettings?: () => void; - label?: string; disabled?: boolean; }; @@ -33,7 +47,6 @@ export function IntegrationSelector({ value, onChange, onOpenSettings, - label, disabled, }: IntegrationSelectorProps) { const [integrations, setIntegrations] = useState([]); @@ -96,24 +109,73 @@ export function IntegrationSelector({ ); } + const plugin = getIntegration(integrationType); + const integrationLabel = plugin?.label || integrationType; + + // No integrations - show error button to add one if (integrations.length === 0) { return ( -
- - + <> + + + setShowNewDialog(false)} + onSuccess={handleNewIntegrationCreated} + open={showNewDialog} + preselectedType={integrationType} + /> + + ); + } + + // Single integration - show connected state with manage button + if (integrations.length === 1) { + const integration = integrations[0]; + + return ( + <> +
+ + + + + + + + + Remove Connection + + setShowNewDialog(true)}> + + Add Another Connection + + + +
+ setShowNewDialog(false)} @@ -121,29 +183,33 @@ export function IntegrationSelector({ open={showNewDialog} preselectedType={integrationType} /> -
+ ); } + // Multiple integrations - show dropdown selector return ( -
- {label && {label}} - + + - New Integration - Manage Integrations - {integrations.length > 0 && } {integrations.map((integration) => ( {integration.name} ))} + + Add Connection + Manage Connections - + setShowNewDialog(false)} @@ -151,7 +217,7 @@ export function IntegrationSelector({ open={showNewDialog} preselectedType={integrationType} /> -
+ ); } diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 000000000..a4e90d4e9 --- /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.tsx b/components/workflow/config/action-config.tsx index 3931cf05e..b0d96b3d9 100644 --- a/components/workflow/config/action-config.tsx +++ b/components/workflow/config/action-config.tsx @@ -1,9 +1,10 @@ "use client"; -import { Settings } from "lucide-react"; +import { HelpCircle, Settings } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { CodeEditor } from "@/components/ui/code-editor"; import { IntegrationIcon } from "@/components/ui/integration-icon"; +import { IntegrationSelector } from "@/components/ui/integration-selector"; import { Label } from "@/components/ui/label"; import { Select, @@ -13,6 +14,13 @@ import { SelectValue, } from "@/components/ui/select"; import { TemplateBadgeInput } from "@/components/ui/template-badge-input"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { IntegrationType } from "@/lib/types/integration"; import { findActionById, getActionsByCategory, @@ -25,6 +33,7 @@ type ActionConfigProps = { config: Record; onUpdateConfig: (key: string, value: string) => void; disabled: boolean; + onOpenIntegrations?: () => void; }; // Database Query fields component @@ -208,6 +217,11 @@ const SYSTEM_ACTIONS: Array<{ id: string; label: string }> = [ 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(() => { @@ -268,6 +282,7 @@ export function ActionConfig({ config, onUpdateConfig, disabled, + onOpenIntegrations, }: ActionConfigProps) { const actionType = (config?.actionType as string) || ""; const categories = useCategoryData(); @@ -303,6 +318,22 @@ 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 ( <>
@@ -364,6 +395,31 @@ export function ActionConfig({
+ {integrationType && ( +
+
+ + + + + + + +

Required for this step to run

+
+
+
+
+ onUpdateConfig("integrationId", id)} + onOpenSettings={onOpenIntegrations} + value={(config?.integrationId as string) || ""} + /> +
+ )} + {/* System actions - hardcoded config fields */} {config?.actionType === "HTTP Request" && ( + ); } if (action.icon) { - return ; + return ; } - return ; + return ; } export function ActionGrid({ @@ -103,50 +102,49 @@ export function ActionGrid({ }); 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 8942683ec..e12de198d 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 ? ( + setShowIntegrationsDialog(true)} + onUpdateConfig={handleUpdateConfig} + /> + ) : 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/package.json b/package.json index 563163c4a..73f30b35e 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 64aaecaae..a86622251 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 From e1be06ec543c529f379fa3956b662009a555b879 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 11 Dec 2025 20:25:06 -0600 Subject: [PATCH 02/11] better ux --- components/settings/integrations-manager.tsx | 232 ++++++------------- components/workflows/user-menu.tsx | 2 +- 2 files changed, 69 insertions(+), 165 deletions(-) diff --git a/components/settings/integrations-manager.tsx b/components/settings/integrations-manager.tsx index 82a9d8d38..7bc35e51a 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"; @@ -47,7 +41,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 +64,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) => { @@ -137,18 +128,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 (
@@ -167,133 +146,58 @@ export function IntegrationsManager({
) : (
- {groupedIntegrations.map((group) => { - // Single item - show flat entry without collapsible - if (group.items.length === 1) { - const integration = group.items[0]; - return ( -
( +
+
+ + {integration.label} + + {integration.name} + +
+
+ - - -
-
- ); - } - - // Multiple items - show collapsible group - return ( - toggleGroup(group.type)} - open={expandedGroups.has(group.type)} - > - - - {group.label} - - ({group.items.length}) - - - - -
- {group.items.map((integration) => ( -
- {integration.name} -
- - - -
-
- ))} -
-
-
- ); - })} + {testingId === integration.id ? ( + + ) : ( + Test + )} + + + +
+
+ ))}
)} diff --git a/components/workflows/user-menu.tsx b/components/workflows/user-menu.tsx index 87f6af08b..43013b132 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)}> From adbee3fe9429e481f2c447e3ca027e77ccf20a78 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 11 Dec 2025 22:31:05 -0600 Subject: [PATCH 03/11] improvements --- .../settings/integration-form-dialog.tsx | 425 ++++++++++++++---- components/settings/integrations-dialog.tsx | 6 +- components/ui/integration-selector.tsx | 193 ++++---- .../config/action-config-renderer.tsx | 1 + components/workflow/workflow-toolbar.tsx | 4 +- 5 files changed, 420 insertions(+), 209 deletions(-) diff --git a/components/settings/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index aeae7939d..b09d602c1 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -1,8 +1,18 @@ "use client"; -import { ArrowLeft, Search } from "lucide-react"; +import { ArrowLeft, Check, Pencil, Search, Trash2, X } 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, @@ -28,6 +38,7 @@ type IntegrationFormDialogProps = { open: boolean; onClose: () => void; onSuccess?: (integrationId: string) => void; + onDelete?: () => void; integration?: Integration | null; mode: "create" | "edit"; preselectedType?: IntegrationType; @@ -55,15 +66,280 @@ 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 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [formData, setFormData] = useState({ name: "", @@ -147,6 +423,26 @@ export function IntegrationFormDialog({ } }; + 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 updateConfig = (key: string, value: string) => { setFormData({ ...formData, @@ -154,66 +450,6 @@ export function IntegrationFormDialog({ }); }; - const renderConfigFields = () => { - if (!formData.type) { - return null; - } - - // 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 -

-
- ); - } - - // Get plugin form fields from registry - const plugin = getIntegration(formData.type); - if (!plugin?.formFields) { - return null; - } - - 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 integrationTypes = getIntegrationTypes(); const filteredIntegrationTypes = useMemo(() => { @@ -255,43 +491,19 @@ export function IntegrationFormDialog({ {step === "select" ? ( -
-
- - setSearchQuery(e.target.value)} - placeholder="Search services..." - value={searchQuery} - /> -
-
- {filteredIntegrationTypes.length === 0 ? ( -

- No services found -

- ) : ( - filteredIntegrationTypes.map((type) => ( - - )) - )} -
-
+ ) : (
- {renderConfigFields()} +
@@ -316,6 +528,16 @@ export function IntegrationFormDialog({ Back )} + {step === "configure" && mode === "edit" && ( + + )} {step === "select" ? ( - @@ -337,6 +559,13 @@ export function IntegrationFormDialog({ )} + +
); } diff --git a/components/settings/integrations-dialog.tsx b/components/settings/integrations-dialog.tsx index 121155d08..f902cc649 100644 --- a/components/settings/integrations-dialog.tsx +++ b/components/settings/integrations-dialog.tsx @@ -93,11 +93,13 @@ export function IntegrationsDialog({ )} - + - diff --git a/components/ui/integration-selector.tsx b/components/ui/integration-selector.tsx index 0b3ecbf54..0152d9812 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -1,36 +1,16 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { - AlertTriangle, - Check, - MoreHorizontal, - Plus, - Settings, - Trash2, -} from "lucide-react"; +import { AlertTriangle, Check, Circle, Pencil, Plus } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; 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"; @@ -40,18 +20,21 @@ type IntegrationSelectorProps = { onChange: (integrationId: string) => void; onOpenSettings?: () => void; disabled?: boolean; + onAddConnection?: () => void; }; export function IntegrationSelector({ integrationType, value, onChange, - onOpenSettings, disabled, + onAddConnection, }: IntegrationSelectorProps) { const [integrations, setIntegrations] = useState([]); const [loading, setLoading] = useState(true); const [showNewDialog, setShowNewDialog] = useState(false); + const [editingIntegration, setEditingIntegration] = + useState(null); const integrationsVersion = useAtomValue(integrationsVersionAtom); const setGlobalIntegrations = useSetAtom(integrationsAtom); const setIntegrationsVersion = useSetAtom(integrationsVersionAtom); @@ -64,7 +47,7 @@ export function IntegrationSelector({ 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); @@ -81,16 +64,6 @@ export function IntegrationSelector({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [integrationType, integrationsVersion]); - const handleValueChange = (newValue: string) => { - if (newValue === "__new__") { - setShowNewDialog(true); - } else if (newValue === "__manage__") { - onOpenSettings?.(); - } else { - onChange(newValue); - } - }; - const handleNewIntegrationCreated = async (integrationId: string) => { await loadIntegrations(); onChange(integrationId); @@ -99,13 +72,25 @@ export function IntegrationSelector({ setIntegrationsVersion((v) => v + 1); }; + const handleEditSuccess = async () => { + await loadIntegrations(); + setEditingIntegration(null); + setIntegrationsVersion((v) => v + 1); + }; + + const handleAddConnection = () => { + if (onAddConnection) { + onAddConnection(); + } else { + setShowNewDialog(true); + } + }; + if (loading) { return ( - +
+
+
); } @@ -119,7 +104,7 @@ export function IntegrationSelector({ - - - - - - - - Remove Connection - - setShowNewDialog(true)}> - - Add Another Connection - - - -
- - setShowNewDialog(false)} - onSuccess={handleNewIntegrationCreated} - open={showNewDialog} - preselectedType={integrationType} - /> - - ); - } - - // Multiple integrations - show dropdown selector + // Show radio-style selection list return ( <> - +
+ {integrations.map((integration) => { + const isSelected = value === integration.id; + const displayName = + integration.name || `${integrationLabel} API Key`; + return ( +
+ + +
+ ); + })} +
+ + {editingIntegration && ( + setEditingIntegration(null)} + onDelete={async () => { + await loadIntegrations(); + setEditingIntegration(null); + setIntegrationsVersion((v) => v + 1); + }} + onSuccess={handleEditSuccess} + open + /> + )} ); } diff --git a/components/workflow/config/action-config-renderer.tsx b/components/workflow/config/action-config-renderer.tsx index 030c20baa..7705763fa 100644 --- a/components/workflow/config/action-config-renderer.tsx +++ b/components/workflow/config/action-config-renderer.tsx @@ -151,6 +151,7 @@ function renderField(
)} - {/* Missing Integrations Section */} + {/* Missing Connections Section */} {missingIntegrations.length > 0 && (

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

{missingIntegrations.map((missing) => ( From 6d751052acd326f5d18f337bbcc6164bcefa98a6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 11 Dec 2025 22:39:09 -0600 Subject: [PATCH 04/11] fixes --- components/ui/integration-selector.tsx | 63 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/components/ui/integration-selector.tsx b/components/ui/integration-selector.tsx index 0152d9812..23dda2f75 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -1,8 +1,8 @@ "use client"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { AlertTriangle, Check, Circle, Pencil, Plus } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { api, type Integration } from "@/lib/api-client"; import { @@ -30,51 +30,68 @@ export function IntegrationSelector({ disabled, onAddConnection, }: IntegrationSelectorProps) { - const [integrations, setIntegrations] = useState([]); - const [loading, setLoading] = useState(true); const [showNewDialog, setShowNewDialog] = useState(false); const [editingIntegration, setEditingIntegration] = useState(null); - const integrationsVersion = useAtomValue(integrationsVersionAtom); - const setGlobalIntegrations = useSetAtom(integrationsAtom); + const [globalIntegrations, setGlobalIntegrations] = useAtom(integrationsAtom); + const integrationsVersion = useRef(0); const setIntegrationsVersion = useSetAtom(integrationsVersionAtom); + const [hasFetched, setHasFetched] = useState(false); - const loadIntegrations = async () => { + // Filter integrations from global cache + const integrations = useMemo( + () => globalIntegrations.filter((i) => i.type === integrationType), + [globalIntegrations, integrationType] + ); + + // Check if we have cached data + const hasCachedData = globalIntegrations.length > 0; + + const loadIntegrations = async (isBackground = false) => { 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) { + if (filtered.length === 1 && !value && !isBackground) { onChange(filtered[0].id); } + setHasFetched(true); } catch (error) { console.error("Failed to load integrations:", error); - } finally { - setLoading(false); } }; useEffect(() => { - loadIntegrations(); + // Always fetch in background, but track if it's the first fetch + loadIntegrations(!hasCachedData); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [integrationType, integrationsVersion]); + }, [integrationType]); + + // Listen for version changes (from other components creating/editing integrations) + useEffect(() => { + const unsubscribe = integrationsVersionAtom.onMount?.((setAtom) => { + // Re-fetch when version changes + loadIntegrations(true); + }); + return unsubscribe; + }, []); const handleNewIntegrationCreated = async (integrationId: string) => { - await loadIntegrations(); + await loadIntegrations(true); 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 + integrationsVersion.current += 1; setIntegrationsVersion((v) => v + 1); }; const handleEditSuccess = async () => { - await loadIntegrations(); + await loadIntegrations(true); setEditingIntegration(null); + integrationsVersion.current += 1; setIntegrationsVersion((v) => v + 1); }; @@ -86,10 +103,15 @@ export function IntegrationSelector({ } }; - if (loading) { + // Only show loading skeleton if we have no cached data and haven't fetched yet + if (!hasCachedData && !hasFetched) { return (
-
+
+
+
+
+
); } @@ -188,8 +210,9 @@ export function IntegrationSelector({ mode="edit" onClose={() => setEditingIntegration(null)} onDelete={async () => { - await loadIntegrations(); + await loadIntegrations(true); setEditingIntegration(null); + integrationsVersion.current += 1; setIntegrationsVersion((v) => v + 1); }} onSuccess={handleEditSuccess} From 1c05d1661c39c98d802d1dab264088f69274502d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 11 Dec 2025 22:45:10 -0600 Subject: [PATCH 05/11] consistent dialogs --- components/settings/api-keys-dialog.tsx | 35 +++++++++++++------ components/settings/index.tsx | 23 +++++++++--- .../settings/integration-form-dialog.tsx | 18 ++++++++-- components/settings/integrations-dialog.tsx | 8 ++--- components/workflow/workflow-toolbar.tsx | 12 ++++--- 5 files changed, 68 insertions(+), 28 deletions(-) diff --git a/components/settings/api-keys-dialog.tsx b/components/settings/api-keys-dialog.tsx index d36339631..f40053b5a 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 5e411465a..df9d9d313 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 b09d602c1..97250771d 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -498,7 +498,14 @@ export function IntegrationFormDialog({ searchQuery={searchQuery} /> ) : ( -
+
{ + e.preventDefault(); + handleSave(); + }} + >
-
+ )} onClose()} + type="button" variant="outline" > Cancel - diff --git a/components/settings/integrations-dialog.tsx b/components/settings/integrations-dialog.tsx index f902cc649..81503bbf6 100644 --- a/components/settings/integrations-dialog.tsx +++ b/components/settings/integrations-dialog.tsx @@ -92,14 +92,12 @@ export function IntegrationsDialog({
)} - - - + diff --git a/components/workflow/workflow-toolbar.tsx b/components/workflow/workflow-toolbar.tsx index 8a6abad0c..5670817f9 100644 --- a/components/workflow/workflow-toolbar.tsx +++ b/components/workflow/workflow-toolbar.tsx @@ -1744,11 +1744,11 @@ function WorkflowIssuesDialog({ )}
- - Cancel + + Cancel @@ -1920,12 +1920,14 @@ function WorkflowDialogsComponent({ the workflow? - - Cancel + - +
+ Cancel + +
From 03e235046600a4fe129c7f4851e14bc308952721 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 11 Dec 2025 23:14:15 -0600 Subject: [PATCH 06/11] fixes --- .../settings/integration-form-dialog.tsx | 1 - components/settings/integrations-dialog.tsx | 1 + components/settings/integrations-manager.tsx | 3 + components/ui/integration-selector.tsx | 55 ++++++++++++++++++- components/workflow/config/action-config.tsx | 6 +- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/components/settings/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index 97250771d..f9c0e3588 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -103,7 +103,6 @@ function SecretField({
+
+ + {editingIntegration && ( + setEditingIntegration(null)} + onDelete={async () => { + await loadIntegrations(true); + setEditingIntegration(null); + integrationsVersion.current += 1; + setIntegrationsVersion((v) => v + 1); + }} + onSuccess={handleEditSuccess} + open + /> + )} + + ); + } + + // Multiple integrations - show radio-style selection list return ( <>
@@ -158,7 +209,7 @@ export function IntegrationSelector({ return (