sunatest / frontend /src /components /agents /pipedream /pipedream-connections-section.tsx
llama1's picture
Upload 781 files
5da4770 verified
'use client';
import React, { useState, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { DataTable, DataTableColumn } from '@/components/ui/data-table';
import { Switch } from '@/components/ui/switch';
import {
AlertCircle,
Settings,
User,
Plus,
Search,
X,
Link,
Trash2,
Edit,
Loader2,
CheckCircle2,
XCircle,
RefreshCw,
ExternalLink
} from 'lucide-react';
import { usePipedreamProfiles, useCreatePipedreamProfile, useUpdatePipedreamProfile, useDeletePipedreamProfile, useConnectPipedreamProfile } from '@/hooks/react-query/pipedream/use-pipedream-profiles';
import { usePipedreamApps, usePipedreamAppIcon } from '@/hooks/react-query/pipedream/use-pipedream';
import { PipedreamRegistry } from '@/components/agents/pipedream/pipedream-registry';
import { PipedreamConnector } from '@/components/agents/pipedream/pipedream-connector';
import { useQueryClient } from '@tanstack/react-query';
import { pipedreamKeys } from '@/hooks/react-query/pipedream/keys';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { PipedreamProfile, CreateProfileRequest } from '@/components/agents/pipedream/pipedream-types';
import type { PipedreamApp } from '@/hooks/react-query/pipedream/utils';
interface PipedreamConnectionsSectionProps {
onConnectNewApp?: (app: { app_slug: string; app_name: string }) => void;
}
interface AppTableProps {
appSlug: string;
appName: string;
profiles: PipedreamProfile[];
appImage?: string;
onConnect: (app: PipedreamApp) => void;
onProfileUpdate: (profile: PipedreamProfile, updates: any) => void;
onProfileDelete: (profile: PipedreamProfile) => void;
onProfileConnect: (profile: PipedreamProfile) => void;
isUpdating?: string;
isDeleting?: string;
isConnecting?: string;
allAppsData?: any;
}
const AppTable: React.FC<AppTableProps> = ({
appSlug,
appName,
profiles,
appImage,
onConnect,
onProfileUpdate,
onProfileDelete,
onProfileConnect,
isUpdating,
isDeleting,
isConnecting,
allAppsData
}) => {
const [editingProfile, setEditingProfile] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [showDeleteDialog, setShowDeleteDialog] = useState<PipedreamProfile | null>(null);
const [showQuickCreate, setShowQuickCreate] = useState(false);
const [newProfileName, setNewProfileName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const { data: iconData } = usePipedreamAppIcon(appSlug, {
enabled: !appImage
});
const createProfile = useCreatePipedreamProfile();
const connectProfile = useConnectPipedreamProfile();
const registryApp = useMemo(() => {
return allAppsData?.apps?.find((app: PipedreamApp) =>
app.name_slug === appSlug ||
app.name.toLowerCase() === appName.toLowerCase()
);
}, [allAppsData, appSlug, appName]);
const mockPipedreamApp: PipedreamApp = useMemo(() => ({
id: appSlug,
name: appName,
name_slug: appSlug,
auth_type: "oauth",
description: `Connect to ${appName}`,
img_src: registryApp?.img_src || "",
custom_fields_json: registryApp?.custom_fields_json || "[]",
categories: registryApp?.categories || [],
featured_weight: 0,
connect: {
allowed_domains: registryApp?.connect?.allowed_domains || null,
base_proxy_target_url: registryApp?.connect?.base_proxy_target_url || "",
proxy_enabled: registryApp?.connect?.proxy_enabled || false,
},
}), [appSlug, appName, registryApp]);
const handleQuickCreate = async () => {
if (!newProfileName.trim()) {
toast.error('Please enter a profile name');
return;
}
setIsCreating(true);
try {
const request: CreateProfileRequest = {
profile_name: newProfileName.trim(),
app_slug: appSlug,
app_name: appName,
is_default: profiles.length === 0,
};
const newProfile = await createProfile.mutateAsync(request);
// Auto-connect the new profile
await connectProfile.mutateAsync({
profileId: newProfile.profile_id,
app: appSlug,
});
setNewProfileName('');
setShowQuickCreate(false);
toast.success('Profile created and connected!');
} catch (error) {
console.error('Error creating profile:', error);
} finally {
setIsCreating(false);
}
};
const handleEdit = (profile: PipedreamProfile) => {
setEditingProfile(profile.profile_id);
setEditName(profile.profile_name);
};
const handleSaveEdit = async (profile: PipedreamProfile) => {
if (!editName.trim()) return;
await onProfileUpdate(profile, { profile_name: editName.trim() });
setEditingProfile(null);
setEditName('');
};
const handleCancelEdit = () => {
setEditingProfile(null);
setEditName('');
};
const columns: DataTableColumn<PipedreamProfile>[] = [
{
id: 'name',
header: 'Profile Name',
width: 'w-1/3',
cell: (profile) => (
<div className="flex items-center gap-2">
{editingProfile === profile.profile_id ? (
<div className="flex items-center gap-2 w-full">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(profile);
if (e.key === 'Escape') handleCancelEdit();
}}
className="h-8 text-sm"
autoFocus
/>
<Button
size="sm"
onClick={() => handleSaveEdit(profile)}
disabled={isUpdating === profile.profile_id}
className="h-8 px-2"
>
{isUpdating === profile.profile_id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<CheckCircle2 className="h-3 w-3" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
className="h-8 px-2"
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<span className="font-medium">{profile.profile_name}</span>
)}
</div>
),
},
{
id: 'status',
header: 'Status',
width: 'w-1/4',
cell: (profile) => (
<div className="flex items-center gap-2">
{profile.is_connected ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="h-2 w-2 rounded-full bg-green-500" />
Connected
</div>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="h-2 w-2 rounded-full bg-destructive" />
Disconnected
</div>
)}
{profile.is_default && (
<Badge variant="outline" className="text-xs">
Default
</Badge>
)}
{!profile.is_active && (
<Badge variant="outline" className="text-xs">
Inactive
</Badge>
)}
</div>
),
},
{
id: 'actions',
header: 'Actions',
width: 'w-1/3',
headerClassName: 'text-right',
className: 'text-right',
cell: (profile) => (
<div className="flex items-center justify-end gap-2">
{!profile.is_connected && (
<Button
size="sm"
variant="ghost"
onClick={() => onProfileConnect(profile)}
disabled={isConnecting === profile.profile_id}
className="h-8 px-2 text-xs"
>
{isConnecting === profile.profile_id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Link className="h-3 w-3" />
)}
Connect
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
>
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(profile)}>
<Edit className="h-4 w-4" />
Edit Name
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onProfileUpdate(profile, { is_default: !profile.is_default })}
>
<CheckCircle2 className="h-4 w-4" />
{profile.is_default ? 'Remove Default' : 'Set as Default'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onProfileUpdate(profile, { is_active: !profile.is_active })}
>
{profile.is_active ? (
<>
<XCircle className="h-4 w-4" />
Deactivate
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
Activate
</>
)}
</DropdownMenuItem>
{profile.is_connected && (
<DropdownMenuItem onClick={() => onProfileConnect(profile)}>
<RefreshCw className="h-4 w-4" />
Reconnect
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteDialog(profile)}
className="text-destructive focus:text-destructive focus:bg-destructive/10"
>
<Trash2 className="h-4 w-4 text-destructive" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
];
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-7 w-7 rounded-lg flex items-center justify-center overflow-hidden">
{(appImage || iconData?.icon_url) ? (
<img
src={appImage || iconData?.icon_url || ''}
alt={appName}
className="h-full w-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<span className={cn(
"text-sm font-semibold text-muted-foreground",
(appImage || iconData?.icon_url) ? "hidden" : "block"
)}>
{appName.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="font-semibold text-sm">{appName}</h3>
<p className="text-xs text-muted-foreground">
{profiles.length} {profiles.length === 1 ? 'profile' : 'profiles'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{showQuickCreate ? (
<div className="flex items-center gap-2">
<Input
placeholder="Profile name"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleQuickCreate();
if (e.key === 'Escape') {
setShowQuickCreate(false);
setNewProfileName('');
}
}}
className="h-8 text-sm w-32"
autoFocus
/>
<Button
size="sm"
onClick={handleQuickCreate}
disabled={isCreating}
className="h-8 px-2"
>
{isCreating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<CheckCircle2 className="h-3 w-3" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowQuickCreate(false);
setNewProfileName('');
}}
className="h-8 px-2"
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Button
size="sm"
variant="link"
onClick={() => onConnect(mockPipedreamApp)}
>
<Plus className="h-3 w-3" />
New Profile
</Button>
</>
)}
</div>
</div>
<DataTable
columns={columns}
data={profiles}
emptyMessage={`No ${appName} profiles found`}
className="bg-card border rounded-lg"
/>
<AlertDialog open={!!showDeleteDialog} onOpenChange={(open) => !open && setShowDeleteDialog(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Profile</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{showDeleteDialog?.profile_name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (showDeleteDialog) {
onProfileDelete(showDeleteDialog);
setShowDeleteDialog(null);
}
}}
className="bg-destructive text-white hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export const PipedreamConnectionsSection: React.FC<PipedreamConnectionsSectionProps> = ({
onConnectNewApp
}) => {
const [showAppBrowser, setShowAppBrowser] = useState(false);
const [showConnector, setShowConnector] = useState(false);
const [selectedApp, setSelectedApp] = useState<PipedreamApp | null>(null);
// Debug logging
React.useEffect(() => {
console.log('State changed: selectedApp:', selectedApp, 'showConnector:', showConnector);
}, [selectedApp, showConnector]);
const [searchQuery, setSearchQuery] = useState('');
const [isUpdating, setIsUpdating] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: profiles, isLoading, error } = usePipedreamProfiles();
const { data: allAppsData } = usePipedreamApps(undefined, '');
const updateProfile = useUpdatePipedreamProfile();
const deleteProfile = useDeletePipedreamProfile();
const connectProfile = useConnectPipedreamProfile();
const handleAppSelect = (app: { app_slug: string; app_name: string }) => {
setShowAppBrowser(false);
if (onConnectNewApp) {
onConnectNewApp(app);
}
};
const handleConnect = (app: PipedreamApp) => {
console.log('handleConnect called with app:', app);
setSelectedApp(app);
setShowConnector(true);
console.log('Set showConnector to true');
};
const handleConnectionComplete = (profileId: string, selectedTools: string[], appName: string, appSlug: string) => {
setShowConnector(false);
setSelectedApp(null);
toast.success(`Connected to ${appName}!`);
queryClient.invalidateQueries({ queryKey: pipedreamKeys.profiles.all() });
};
const handleProfileUpdate = async (profile: PipedreamProfile, updates: any) => {
setIsUpdating(profile.profile_id);
try {
await updateProfile.mutateAsync({
profileId: profile.profile_id,
request: updates,
});
toast.success('Profile updated successfully');
} catch (error) {
console.error('Error updating profile:', error);
} finally {
setIsUpdating(null);
}
};
const handleProfileDelete = async (profile: PipedreamProfile) => {
setIsDeleting(profile.profile_id);
try {
await deleteProfile.mutateAsync(profile.profile_id);
toast.success('Profile deleted successfully');
} catch (error) {
console.error('Error deleting profile:', error);
} finally {
setIsDeleting(null);
}
};
const handleProfileConnect = async (profile: PipedreamProfile) => {
setIsConnecting(profile.profile_id);
try {
await connectProfile.mutateAsync({
profileId: profile.profile_id,
app: profile.app_slug,
});
toast.success('Profile connected successfully');
} catch (error) {
console.error('Error connecting profile:', error);
} finally {
setIsConnecting(null);
}
};
const profilesByApp = profiles?.reduce((acc, profile) => {
const key = profile.app_slug;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(profile);
return acc;
}, {} as Record<string, PipedreamProfile[]>) || {};
const filteredProfilesByApp = useMemo(() => {
if (!searchQuery.trim()) {
return profilesByApp;
}
const query = searchQuery.toLowerCase();
const filtered: Record<string, PipedreamProfile[]> = {};
Object.entries(profilesByApp).forEach(([appSlug, appProfiles]) => {
const appName = appProfiles[0]?.app_name.toLowerCase() || '';
const matchingProfiles = appProfiles.filter(profile =>
profile.profile_name.toLowerCase().includes(query) ||
profile.app_name.toLowerCase().includes(query)
);
if (appName.includes(query) || matchingProfiles.length > 0) {
filtered[appSlug] = appName.includes(query) ? appProfiles : matchingProfiles;
}
});
return filtered;
}, [profilesByApp, searchQuery]);
const totalProfiles = profiles?.length || 0;
const connectedProfiles = profiles?.filter(p => p.is_connected).length || 0;
const uniqueApps = Object.keys(profilesByApp).length;
const filteredAppsCount = Object.keys(filteredProfilesByApp).length;
const filteredProfilesCount = Object.values(filteredProfilesByApp).flat().length;
if (isLoading) {
return (
<div className="space-y-6">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<div className="rounded-md border">
<div className="p-4 space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load credential profiles. Please try again.
</AlertDescription>
</Alert>
);
}
if (totalProfiles === 0) {
return (
<>
<Card className="border-dashed">
<CardContent className="p-8 text-center">
<div className="space-y-4">
<div className="p-3 rounded-full bg-primary/10 w-fit mx-auto">
<User className="h-6 w-6 text-primary" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">No credential profiles yet</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Connect your favorite apps to create credential profiles for your agents
</p>
</div>
<Button
onClick={() => setShowAppBrowser(true)}
>
<Plus className="h-4 w-4" />
Connect App
</Button>
</div>
</CardContent>
</Card>
<Dialog open={showAppBrowser} onOpenChange={setShowAppBrowser}>
<DialogContent className="p-0 max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="sr-only">
<DialogTitle>Browse Apps</DialogTitle>
<DialogDescription>
Select an app to create a credential profile
</DialogDescription>
</DialogHeader>
<PipedreamRegistry
mode='profile-only'
showAgentSelector={false}
onAppSelected={handleAppSelect}
/>
</DialogContent>
</Dialog>
</>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{searchQuery
? `${filteredAppsCount} ${filteredAppsCount === 1 ? 'app' : 'apps'} with ${filteredProfilesCount} ${filteredProfilesCount === 1 ? 'profile' : 'profiles'} found`
: `${uniqueApps} ${uniqueApps === 1 ? 'app' : 'apps'} with ${totalProfiles} ${totalProfiles === 1 ? 'profile' : 'profiles'} (${connectedProfiles} connected)`
}
</p>
</div>
<Button
onClick={() => setShowAppBrowser(true)}
size="sm"
>
<Plus className="h-4 w-4" />
Connect New App
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search apps and profiles..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-12 rounded-xl bg-muted/50 border-0 focus:bg-background focus:ring-2 focus:ring-primary/20 transition-all"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery('')}
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{Object.keys(filteredProfilesByApp).length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-8 text-center">
<div className="space-y-4">
<div className="p-3 rounded-full bg-muted/50 w-fit mx-auto">
<Search className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">No results found</h3>
<p className="text-sm text-muted-foreground">
Try adjusting your search terms or browse all apps
</p>
</div>
<Button
onClick={() => setSearchQuery('')}
variant="outline"
size="sm"
>
Clear search
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{Object.entries(filteredProfilesByApp)
.sort(([, a], [, b]) => {
const aConnected = a.filter(p => p.is_connected).length;
const bConnected = b.filter(p => p.is_connected).length;
if (aConnected !== bConnected) return bConnected - aConnected;
return b.length - a.length;
})
.map(([appSlug, appProfiles]) => {
const registryApp = allAppsData?.apps?.find((app: PipedreamApp) =>
app.name_slug === appSlug ||
app.name.toLowerCase() === appProfiles[0].app_name.toLowerCase()
);
return (
<AppTable
key={appSlug}
appSlug={appSlug}
appName={appProfiles[0].app_name}
profiles={appProfiles}
appImage={registryApp?.img_src}
onConnect={handleConnect}
onProfileUpdate={handleProfileUpdate}
onProfileDelete={handleProfileDelete}
onProfileConnect={handleProfileConnect}
isUpdating={isUpdating}
isDeleting={isDeleting}
isConnecting={isConnecting}
allAppsData={allAppsData}
/>
);
})}
</div>
)}
<Dialog open={showAppBrowser} onOpenChange={setShowAppBrowser}>
<DialogContent className="p-0 max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="sr-only">
<DialogTitle>Browse Apps</DialogTitle>
<DialogDescription>
Select an app to create a credential profile
</DialogDescription>
</DialogHeader>
<PipedreamRegistry
mode='profile-only'
showAgentSelector={false}
onAppSelected={handleAppSelect}
/>
</DialogContent>
</Dialog>
{selectedApp && (
<PipedreamConnector
app={selectedApp}
open={showConnector}
onOpenChange={setShowConnector}
onComplete={handleConnectionComplete}
mode="profile-only"
/>
)}
</div>
);
};