diff --git a/apps/mesh/src/web/components/create-project-template-dialog.tsx b/apps/mesh/src/web/components/create-project-template-dialog.tsx new file mode 100644 index 0000000000..8dc4e00933 --- /dev/null +++ b/apps/mesh/src/web/components/create-project-template-dialog.tsx @@ -0,0 +1,483 @@ +/** + * Create Project Template Dialog + * + * A full-width dialog for creating a new project. Displays: + * - Left sidebar: category navigation (All templates, My templates, Featured by Deco categories) + * - Right content: action cards (Start from scratch, Import file, Import from GitHub) + * and a searchable template grid fetched from a bound template registry connection. + */ + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + useConnections, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { + PROJECT_TEMPLATE_REGISTRY_BINDING, + type ProjectTemplate, +} from "@decocms/bindings"; +import { connectionImplementsBinding } from "@/web/hooks/use-binding"; +import { Dialog, DialogContent } from "@deco/ui/components/dialog.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { Plus, SearchMd, Download01, Grid01, User01 } from "@untitledui/icons"; +import { KEYS } from "@/web/lib/query-keys"; +import { CreateProjectDialog } from "./create-project-dialog"; +import { TemplateOnboardingWizard } from "./template-onboarding-wizard"; +import type { CollectionListOutput } from "@decocms/bindings/collections"; + +// ============================================================================ +// Types +// ============================================================================ + +interface CreateProjectTemplateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type DialogView = "template-selection" | "onboarding"; + +// ============================================================================ +// Hooks +// ============================================================================ + +/** + * Find the first connection that implements PROJECT_TEMPLATE_REGISTRY_BINDING + */ +function useTemplateRegistryConnection() { + const connections = useConnections(); + if (!connections) return null; + return ( + connections.find((conn) => + connectionImplementsBinding( + conn, + PROJECT_TEMPLATE_REGISTRY_BINDING as never, + ), + ) ?? null + ); +} + +/** + * Fetch templates from the registry connection + */ +function useTemplates(registryConnectionId: string | null, search: string) { + const { org } = useProjectContext(); + const client = useMCPClient({ + connectionId: registryConnectionId, + orgId: org.id, + }); + + return useQuery({ + queryKey: KEYS.toolCall( + registryConnectionId ?? "no-registry", + "COLLECTION_PROJECT_TEMPLATE_LIST", + JSON.stringify({ search }), + ), + queryFn: async () => { + const searchWhere = search.trim() + ? { + operator: "or" as const, + conditions: [ + { + field: ["title"], + operator: "contains" as const, + value: search, + }, + { + field: ["description"], + operator: "contains" as const, + value: search, + }, + ], + } + : undefined; + + const result = (await client.callTool({ + name: "COLLECTION_PROJECT_TEMPLATE_LIST", + arguments: { + limit: 100, + ...(searchWhere && { where: searchWhere }), + }, + })) as { structuredContent?: unknown }; + + const payload = (result.structuredContent ?? + result) as CollectionListOutput; + return payload.items ?? []; + }, + enabled: !!registryConnectionId, + staleTime: 5 * 60 * 1000, + }); +} + +// ============================================================================ +// Sub-components +// ============================================================================ + +/** GitHub icon (inline SVG since @untitledui/icons may not have it) */ +function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +/** Sidebar category item */ +function CategoryItem({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** Action card for Start from scratch / Import file / Import from github */ +function ActionCard({ + icon, + label, + onClick, + disabled, + disabledReason, +}: { + icon: React.ReactNode; + label: string; + onClick?: () => void; + disabled?: boolean; + disabledReason?: string; +}) { + const card = ( + + ); + + if (disabled && disabledReason) { + return ( + + {card} + {disabledReason} + + ); + } + + return card; +} + +/** Template card in the grid */ +function TemplateCard({ + template, + onClick, +}: { + template: ProjectTemplate; + onClick: () => void; +}) { + return ( + + ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function CreateProjectTemplateDialog({ + open, + onOpenChange, +}: CreateProjectTemplateDialogProps) { + const [view, setView] = useState("template-selection"); + const [selectedTemplate, setSelectedTemplate] = + useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [showScratchDialog, setShowScratchDialog] = useState(false); + + // Find template registry connection + const registryConnection = useTemplateRegistryConnection(); + const { data: templates = [], isLoading } = useTemplates( + registryConnection?.id ?? null, + searchQuery, + ); + + // Extract unique categories from templates + const categories = Array.from( + new Set(templates.map((t) => t.category).filter(Boolean)), + ).sort(); + + // Filter templates by selected category + const filteredTemplates = selectedCategory + ? templates.filter((t) => t.category === selectedCategory) + : templates; + + const handleSelectTemplate = (template: ProjectTemplate) => { + setSelectedTemplate(template); + setView("onboarding"); + }; + + const handleStartFromScratch = () => { + onOpenChange(false); + setShowScratchDialog(true); + }; + + const handleBackToSelection = () => { + setView("template-selection"); + setSelectedTemplate(null); + }; + + const handleClose = () => { + onOpenChange(false); + // Reset state after animation + setTimeout(() => { + setView("template-selection"); + setSelectedTemplate(null); + setSelectedCategory(null); + setSearchQuery(""); + }, 200); + }; + + const handleScratchDialogClose = (isOpen: boolean) => { + setShowScratchDialog(isOpen); + }; + + return ( + <> + + + {view === "template-selection" ? ( +
+ {/* Left Sidebar */} +
+ {/* Top menu items */} +
+ setSelectedCategory(null)} + /> + +
+ + {/* Category list */} + {categories.length > 0 && ( +
+
+ + FEATURED BY DECO + +
+ {categories.map((category) => ( + setSelectedCategory(category)} + /> + ))} +
+ )} +
+ + {/* Right Content */} +
+ {/* Top Section: Action Cards */} +
+

+ Create a new project +

+
+ } + label="Start from scratch" + onClick={handleStartFromScratch} + /> + } + label="Import file" + disabled + disabledReason="Coming soon" + /> + } + label="Import from github" + disabled + disabledReason="Coming soon" + /> +
+
+ + {/* Templates Section */} +
+ {/* Templates Header */} +
+

+ Templates +

+ + setSearchQuery(e.target.value)} + placeholder="Search for a template..." + className="w-[300px] h-8 border-0 shadow-none focus-visible:ring-0 text-sm" + /> +
+ + {/* Templates Grid */} +
+ {!registryConnection ? ( +
+ +

+ No template registry connected +

+

+ Connect a template registry to browse project + templates +

+
+ ) : isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : filteredTemplates.length === 0 ? ( +
+ +

+ {searchQuery + ? "No templates match your search" + : "No templates available"} +

+
+ ) : ( +
+ {filteredTemplates.map((template) => ( + handleSelectTemplate(template)} + /> + ))} +
+ )} +
+
+
+
+ ) : ( + + )} + +
+ + {/* Existing Create Project Dialog (for Start from scratch) */} + + + ); +} diff --git a/apps/mesh/src/web/components/sidebar/projects-section.tsx b/apps/mesh/src/web/components/sidebar/projects-section.tsx index 998d4b0911..02790be598 100644 --- a/apps/mesh/src/web/components/sidebar/projects-section.tsx +++ b/apps/mesh/src/web/components/sidebar/projects-section.tsx @@ -17,7 +17,7 @@ import { import { ChevronDown, ChevronRight, Plus } from "@untitledui/icons"; import { ORG_ADMIN_PROJECT_SLUG, useProjectContext } from "@decocms/mesh-sdk"; import { useProjects, type ProjectWithBindings } from "@/web/hooks/use-project"; -import { CreateProjectDialog } from "@/web/components/create-project-dialog"; +import { CreateProjectTemplateDialog } from "@/web/components/create-project-template-dialog"; import { cn } from "@deco/ui/lib/utils.ts"; function ProjectIcon({ @@ -168,7 +168,7 @@ function ProjectsSectionContent() { - diff --git a/apps/mesh/src/web/components/template-onboarding-wizard.tsx b/apps/mesh/src/web/components/template-onboarding-wizard.tsx new file mode 100644 index 0000000000..c818ae771c --- /dev/null +++ b/apps/mesh/src/web/components/template-onboarding-wizard.tsx @@ -0,0 +1,678 @@ +/** + * Template Onboarding Wizard + * + * Multi-step wizard shown after selecting a project template. + * Steps: + * 1. Template overview with plugin list + * 2..N. Plugin connection setup for each plugin requiring an MCP binding + * N+1. Project details (name, slug, description) + * Submit: Creates project with enabledPlugins + plugin configs + */ + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { toast } from "sonner"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { + ORG_ADMIN_PROJECT_SLUG, + SELF_MCP_ALIAS_ID, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import type { ProjectTemplate, TemplatePlugin } from "@decocms/bindings"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@deco/ui/components/form.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { ArrowLeft, Check, ChevronRight, Container } from "@untitledui/icons"; +import { KEYS } from "@/web/lib/query-keys"; +import { generateSlug, isValidSlug } from "@/web/lib/slug"; +import { BindingSelector } from "./binding-selector"; +import { sourcePlugins } from "@/web/plugins"; +import { pluginRootSidebarItems } from "@/web/index"; +import type { Project } from "@/web/hooks/use-project"; + +// ============================================================================ +// Types +// ============================================================================ + +interface TemplateOnboardingWizardProps { + template: ProjectTemplate; + onBack: () => void; + onClose: () => void; +} + +type WizardStep = "overview" | "connections" | "details"; + +type ProjectCreateOutput = { project: Project }; + +const projectFormSchema = z.object({ + name: z.string().min(1, "Name is required").max(200), + slug: z.string().min(1, "Slug is required").max(100), + description: z.string().max(1000).optional(), +}); + +type ProjectFormData = z.infer; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Get plugin metadata from sidebar items */ +function getPluginMeta(pluginId: string) { + return pluginRootSidebarItems.find((item) => item.pluginId === pluginId); +} + +/** Get source plugin definition */ +function getSourcePlugin(pluginId: string) { + return sourcePlugins.find((p) => p.id === pluginId); +} + +/** Check if a plugin requires an MCP binding */ +function pluginRequiresBinding(pluginId: string): boolean { + const plugin = getSourcePlugin(pluginId); + if (!plugin) return false; + if ( + (plugin as { requiresMcpBinding?: boolean }).requiresMcpBinding === true + ) { + return true; + } + return plugin.binding !== undefined; +} + +/** Get plugins that require connection setup */ +function getPluginsRequiringConnections( + templatePlugins: TemplatePlugin[], +): TemplatePlugin[] { + return templatePlugins.filter((tp) => pluginRequiresBinding(tp.pluginId)); +} + +// ============================================================================ +// Sub-components +// ============================================================================ + +/** Step indicator in the wizard header */ +function StepIndicator({ + steps, + currentIndex, +}: { + steps: { key: string; label: string }[]; + currentIndex: number; +}) { + return ( +
+ {steps.map((step, i) => ( +
+
+ {i < currentIndex ? : i + 1} +
+ + {step.label} + + {i < steps.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +/** Overview step showing template info and plugins */ +function OverviewStep({ template }: { template: ProjectTemplate }) { + return ( +
+ {/* Template header */} +
+
+
+

+ {template.title} +

+ {template.description && ( +

+ {template.description} +

+ )} + + {template.category} + +
+
+ + {/* Plugin list */} +
+

+ Included plugins +

+
+ {template.plugins.map((tp) => { + const meta = getPluginMeta(tp.pluginId); + const plugin = getSourcePlugin(tp.pluginId); + const needsConnection = pluginRequiresBinding(tp.pluginId); + + return ( +
+
+ {meta?.icon ?? } +
+
+
+ {meta?.label ?? tp.pluginId} +
+ {plugin?.description && ( +

+ {plugin.description} +

+ )} +
+
+ {needsConnection && ( + + Requires connection + + )} + {tp.required !== false && ( + + Required + + )} +
+
+ ); + })} +
+
+
+ ); +} + +/** Connection setup step for plugins requiring MCP bindings */ +function ConnectionsStep({ + template, + connectionBindings, + onConnectionChange, +}: { + template: ProjectTemplate; + connectionBindings: Record; + onConnectionChange: (pluginId: string, connectionId: string | null) => void; +}) { + const pluginsNeedingConnections = getPluginsRequiringConnections( + template.plugins, + ); + + return ( +
+
+

+ Connect your services +

+

+ Select or create connections for the plugins in this template. +

+
+ +
+ {pluginsNeedingConnections.map((tp) => { + const meta = getPluginMeta(tp.pluginId); + const plugin = getSourcePlugin(tp.pluginId); + + return ( +
+
+
+ {meta?.icon ?? } +
+
+
+ {meta?.label ?? tp.pluginId} +
+ {plugin?.description && ( +

+ {plugin.description} +

+ )} +
+ {tp.required === false && ( + + Optional + + )} +
+
+ + + onConnectionChange(tp.pluginId, value) + } + binding={plugin?.binding} + bindingType={tp.defaultConnectionAppId ?? undefined} + placeholder="Select connection..." + className="w-64" + /> +
+
+ ); + })} +
+
+ ); +} + +/** Project details form step */ +function DetailsStep({ + form, + slugManuallyEdited, + onSlugManuallyEdited, + orgSlug, + isPending, + bannerColor, +}: { + form: ReturnType>; + slugManuallyEdited: boolean; + onSlugManuallyEdited: (v: boolean) => void; + orgSlug: string; + isPending: boolean; + bannerColor: string; +}) { + const name = form.watch("name"); + const slug = form.watch("slug"); + const isSlugReserved = slug === ORG_ADMIN_PROJECT_SLUG; + const isSlugInvalid = slug.length > 0 && !isValidSlug(slug); + + const handleNameChange = (e: React.ChangeEvent) => { + const newName = e.target.value; + form.setValue("name", newName); + if (!slugManuallyEdited) { + form.setValue("slug", generateSlug(newName)); + } + }; + + const handleSlugChange = (e: React.ChangeEvent) => { + onSlugManuallyEdited(true); + form.setValue( + "slug", + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ); + }; + + return ( +
+
+

Project details

+

+ Give your new project a name and description. +

+
+ + {/* Banner Preview */} +
+
+
+ {name?.charAt(0)?.toUpperCase() || "P"} +
+
+
+ +
+ ( + + Project Name * + + + + + + )} + /> + + ( + + Slug * +
+ + /{orgSlug}/ + + + + +
+ {isSlugReserved && ( +

+ "{ORG_ADMIN_PROJECT_SLUG}" is a reserved slug +

+ )} + {isSlugInvalid && !isSlugReserved && ( +

+ Slug must be lowercase alphanumeric with hyphens only +

+ )} + +
+ )} + /> + + ( + + Description + +