From a9e986e68012c68fd46cdb73661837f2dff756b3 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Sun, 12 Oct 2025 06:56:15 +0000 Subject: [PATCH] feat: project selection, creation for each connectors (#486) - Added Project Selection for Each Connectors - Updated the Layout from list the cards layout --- apps/web/components/views/integrations.tsx | 638 +++++++++++++++++---- 1 file changed, 532 insertions(+), 106 deletions(-) diff --git a/apps/web/components/views/integrations.tsx b/apps/web/components/views/integrations.tsx index 5a6d09d0..f020a3d6 100644 --- a/apps/web/components/views/integrations.tsx +++ b/apps/web/components/views/integrations.tsx @@ -16,6 +16,13 @@ import { DialogPortal, DialogTitle, } from "@repo/ui/components/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu" +import { Input } from "@repo/ui/components/input" import { Skeleton } from "@repo/ui/components/skeleton" import type { ConnectionResponseSchema } from "@repo/validation/api" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" @@ -23,16 +30,20 @@ import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" import { useCustomer } from "autumn-js/react" import { Check, + ChevronDown, Copy, DownloadIcon, + FolderIcon, KeyIcon, + Loader, + Plus, Smartphone, Trash2, } from "lucide-react" import { motion } from "motion/react" import Image from "next/image" import { useSearchParams } from "next/navigation" -import { useEffect, useId, useState } from "react" +import { startTransition, useEffect, useId, useMemo, useState } from "react" import { toast } from "sonner" import type { z } from "zod" import { analytics } from "@/lib/analytics" @@ -40,6 +51,15 @@ import { useProject } from "@/stores" type Connection = z.infer +interface Project { + id: string + name: string + containerTag: string + createdAt: string + updatedAt: string + isExperimental?: boolean +} + const CONNECTORS = { "google-drive": { title: "Google Drive", @@ -56,10 +76,32 @@ const CONNECTORS = { description: "Access your Microsoft Office documents", icon: OneDrive, }, + "more-coming": { + title: "More Coming Soon", + description: "Additional integrations are in development", + icon: () => ( + + More Coming Soon Icon + + + ), + }, } as const type ConnectorProvider = keyof typeof CONNECTORS +const COMING_SOON_CONNECTOR = "more-coming" as const + const ChromeIcon = ({ className }: { className?: string }) => ( ("") const [raycastCopied, setRaycastCopied] = useState(false) const [hasTriggeredRaycast, setHasTriggeredRaycast] = useState(false) + const [selectedProjectForConnection, setSelectedProjectForConnection] = + useState>({}) + const [showCreateProjectForm, setShowCreateProjectForm] = useState(false) + const [newProjectName, setNewProjectName] = useState("") + const [creatingProjectForConnector, setCreatingProjectForConnector] = + useState(null) + const [connectingProvider, setConnectingProvider] = useState( + null, + ) const apiKeyId = useId() const raycastApiKeyId = useId() @@ -165,6 +216,20 @@ export function IntegrationsView() { refetchInterval: 60 * 1000, }) + const { data: projects = [] } = useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const response = await $fetch("@get/projects") + + if (response.error) { + throw new Error(response.error?.message || "Failed to load projects") + } + + return response.data?.projects || [] + }, + staleTime: 30 * 1000, + }) + useEffect(() => { if (connectionsError) { toast.error("Failed to load connections", { @@ -176,19 +241,44 @@ export function IntegrationsView() { } }, [connectionsError]) + useEffect(() => { + if (selectedProject) { + setSelectedProjectForConnection((prev) => { + const updatedProjects = { ...prev } + let hasChanges = false + + Object.keys(CONNECTORS).forEach((provider) => { + if (!updatedProjects[provider]) { + updatedProjects[provider] = selectedProject + hasChanges = true + } + }) + + return hasChanges ? updatedProjects : prev + }) + } + }, [selectedProject]) + const addConnectionMutation = useMutation({ mutationFn: async (provider: ConnectorProvider) => { + if (provider === COMING_SOON_CONNECTOR) { + throw new Error("This integration is coming soon!") + } + if (!canAddConnection && !isProUser) { throw new Error( "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.", ) } + const projectToUse = + selectedProjectForConnection[provider] || selectedProject + const response = await $fetch("@post/connections/:provider", { params: { provider }, body: { redirectUrl: window.location.href, - containerTags: [selectedProject], + containerTags: [projectToUse], }, }) @@ -203,11 +293,15 @@ export function IntegrationsView() { analytics.connectionAdded(provider) analytics.connectionAuthStarted() if (data?.authLink) { + setConnectingProvider(provider) window.location.href = data.authLink + } else { + setConnectingProvider(null) } }, onError: (error, provider) => { analytics.connectionAuthFailed() + setConnectingProvider(null) toast.error(`Failed to connect ${provider}`, { description: error instanceof Error ? error.message : "Unknown error", }) @@ -232,6 +326,40 @@ export function IntegrationsView() { }, }) + const createProjectMutation = useMutation({ + mutationFn: async (name: string) => { + const response = await $fetch("@post/projects", { + body: { name }, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to create project") + } + + return response.data + }, + onSuccess: (newProject) => { + toast.success("Project created successfully!") + startTransition(() => { + setNewProjectName("") + setShowCreateProjectForm(false) + if (newProject?.containerTag && creatingProjectForConnector) { + setSelectedProjectForConnection((prev) => ({ + ...prev, + [creatingProjectForConnector]: newProject.containerTag, + })) + } + setCreatingProjectForConnector(null) + }) + queryClient.invalidateQueries({ queryKey: ["projects"] }) + }, + onError: (error) => { + toast.error("Failed to create project", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + const createApiKeyMutation = useMutation({ mutationFn: async () => { const res = await authClient.apiKey.create({ @@ -262,7 +390,7 @@ export function IntegrationsView() { if (!org?.id) { throw new Error("Organization ID is required") } - + const res = await authClient.apiKey.create({ metadata: { organizationId: org?.id, @@ -350,6 +478,66 @@ export function IntegrationsView() { createRaycastApiKeyMutation.mutate() } + const validateProjectName = (name: string): string | null => { + const trimmed = name.trim() + if (!trimmed) { + return "Project name is required" + } + if (trimmed.length < 2) { + return "Project name must be at least 2 characters" + } + if (trimmed.length > 50) { + return "Project name must be less than 50 characters" + } + // Allow alphanumeric, spaces, hyphens, and underscores + if (!/^[a-zA-Z0-9\s\-_]+$/.test(trimmed)) { + return "Project name can only contain letters, numbers, spaces, hyphens, and underscores" + } + return null + } + + const handleCreateProject = () => { + const validationError = validateProjectName(newProjectName) + if (validationError) { + toast.error(validationError) + return + } + createProjectMutation.mutate(newProjectName.trim()) + } + + const handleCreateProjectCancel = () => { + setShowCreateProjectForm(false) + setNewProjectName("") + setCreatingProjectForConnector(null) + } + + const getProjectName = (containerTag: string) => { + if (containerTag === selectedProject) { + return "Default Project" + } + const project = projects.find( + (p: Project) => p.containerTag === containerTag, + ) + return project?.name || "Unknown Project" + } + + const updateProjectForConnector = (provider: string, projectTag: string) => { + setSelectedProjectForConnection((prev) => ({ + ...prev, + [provider]: projectTag, + })) + } + + const filteredProjects = useMemo( + () => + projects.filter( + (project: Project) => + project.containerTag !== selectedProject && + project.name !== "Default Project", + ), + [projects, selectedProject], + ) + return (
{/* iOS Shortcuts */} @@ -381,9 +569,23 @@ export function IntegrationsView() { width={20} height={20} /> - {createApiKeyMutation.isPending - ? "Creating..." - : "Add Memory Shortcut"} + + {createApiKeyMutation.isPending ? ( + <> + + Creating... + + ) : ( + "Add Memory Shortcut" + )} +
@@ -444,9 +660,25 @@ export function IntegrationsView() { disabled={createRaycastApiKeyMutation.isPending} > - {createRaycastApiKeyMutation.isPending - ? "Generating..." - : "Get API Key"} + + {createRaycastApiKeyMutation.isPending ? ( + <> + + Generating... + + ) : ( + "Get API Key" + )} + - + )} {/* All Connections with Status */} {connectionsLoading ? ( -
- {Object.keys(CONNECTORS).map((_, i) => ( - + {Object.keys(CONNECTORS).map((_, _i) => ( +
- - + +
))}
) : ( -
- {Object.entries(CONNECTORS).map(([provider, config], index) => { +
+ {Object.entries(CONNECTORS).map(([provider, config]) => { const Icon = config.icon const connection = connections.find( (conn) => conn.provider === provider, ) const isConnected = !!connection + const isMoreComing = provider === COMING_SOON_CONNECTOR return ( - -
- -
-
-

+

+
+
+ +
+
+

{config.title} -

- {isConnected ? ( -
+

+ {isMoreComing ? ( +
+
+ + Coming Soon + +
+ ) : isConnected ? ( +
Connected
) : ( -
+
Disconnected @@ -632,72 +872,170 @@ export function IntegrationsView() {
)}
-

- {config.description} -

- {connection?.email && ( -

- {connection.email} -

- )}
-
- -
- {isConnected ? ( - + deleteConnectionMutation.mutate(connection.id) + } + size="sm" + variant="ghost" > - - - ) : ( -
-
-
- - Disconnected - -
- - - -
+ + )}
- + +

+ {config.description} +

+ + {connection?.email && !isMoreComing && ( +

+ {connection.email} +

+ )} + + {!isConnected && !isMoreComing && ( +
+ + + + + + + updateProjectForConnector( + provider, + selectedProject, + ) + } + className="flex items-center gap-2" + > + + Default Project + {(selectedProjectForConnection[provider] || + selectedProject) === selectedProject && ( + + )} + + + {filteredProjects.length > 0 ? ( + filteredProjects.map((project: Project) => ( + + updateProjectForConnector( + provider, + project.containerTag, + ) + } + className="flex items-center gap-2" + > + + + {project.name} + + {(selectedProjectForConnection[provider] || + selectedProject) === + project.containerTag && ( + + )} + + )) + ) : ( + + No additional projects available + + )} + + { + setCreatingProjectForConnector(provider) + setShowCreateProjectForm(true) + }} + className="flex items-center gap-2 text-muted-foreground" + > + + Create New Project + + + + + +
+ )} + + {isMoreComing && ( +
+ +
+ )} +
) })}
@@ -932,6 +1270,94 @@ export function IntegrationsView() { + + { + if (!open) { + handleCreateProjectCancel() + } + setShowCreateProjectForm(open) + }} + > + + + + + Create New Project + + + +
+
+ + setNewProjectName(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + newProjectName.trim() && + !validateProjectName(newProjectName) && + !createProjectMutation.isPending + ) { + handleCreateProject() + } + }} + className="w-full" + autoFocus + disabled={createProjectMutation.isPending} + /> +
+ +
+ + +
+
+
+
+
) }