new.tsx439 lines · main
| 1 | // @ts-nocheck |
| 2 | import { zodResolver } from '@hookform/resolvers/zod' |
| 3 | import { useParams } from 'common' |
| 4 | import { isEqual } from 'lodash' |
| 5 | import { AlertCircle, Book, Check } from 'lucide-react' |
| 6 | import { useRouter } from 'next/router' |
| 7 | import { useEffect, useId, useMemo, useState } from 'react' |
| 8 | import { useForm } from 'react-hook-form' |
| 9 | import { toast } from 'sonner' |
| 10 | import { |
| 11 | AiIconAnimation, |
| 12 | Button, |
| 13 | cn, |
| 14 | Command, |
| 15 | CommandEmpty, |
| 16 | CommandGroup, |
| 17 | CommandInput, |
| 18 | CommandItem, |
| 19 | CommandList, |
| 20 | Form, |
| 21 | FormControl, |
| 22 | FormField, |
| 23 | FormItem, |
| 24 | Input, |
| 25 | Label, |
| 26 | Popover, |
| 27 | PopoverContent, |
| 28 | PopoverTrigger, |
| 29 | Tooltip, |
| 30 | TooltipContent, |
| 31 | TooltipTrigger, |
| 32 | } from 'ui' |
| 33 | import * as z from 'zod' |
| 34 | |
| 35 | import { EDGE_FUNCTION_TEMPLATES } from '@/components/interfaces/Functions/Functions.templates' |
| 36 | import { DefaultLayout } from '@/components/layouts/DefaultLayout' |
| 37 | import EdgeFunctionsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout' |
| 38 | import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' |
| 39 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 40 | import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' |
| 41 | import { FileExplorerAndEditor } from '@/components/ui/FileExplorerAndEditor' |
| 42 | import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' |
| 43 | import { useEdgeFunctionDeployMutation } from '@/data/edge-functions/edge-functions-deploy-mutation' |
| 44 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 45 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 46 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 47 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 48 | import { BASE_PATH } from '@/lib/constants' |
| 49 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 50 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 51 | |
| 52 | // Array of adjectives and nouns for random function name generation |
| 53 | const ADJECTIVES = [ |
| 54 | 'quick', |
| 55 | 'clever', |
| 56 | 'bright', |
| 57 | 'swift', |
| 58 | 'rapid', |
| 59 | 'smart', |
| 60 | 'smooth', |
| 61 | 'dynamic', |
| 62 | 'super', |
| 63 | 'hyper', |
| 64 | ] |
| 65 | const NOUNS = [ |
| 66 | 'function', |
| 67 | 'handler', |
| 68 | 'processor', |
| 69 | 'responder', |
| 70 | 'worker', |
| 71 | 'service', |
| 72 | 'api', |
| 73 | 'endpoint', |
| 74 | 'action', |
| 75 | 'task', |
| 76 | ] |
| 77 | |
| 78 | // Function name validation regex - only allows alphanumeric characters, hyphens, and underscores |
| 79 | const FUNCTION_NAME_REGEX = /^[A-Za-z0-9_-]+$/ |
| 80 | |
| 81 | // Define form schema with zod |
| 82 | const FormSchema = z.object({ |
| 83 | functionName: z |
| 84 | .string() |
| 85 | .min(1, 'Function name is required') |
| 86 | .regex(FUNCTION_NAME_REGEX, 'Only letters, numbers, hyphens, and underscores allowed'), |
| 87 | }) |
| 88 | |
| 89 | // Generate a random function name |
| 90 | const generateRandomFunctionName = () => { |
| 91 | const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] |
| 92 | const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)] |
| 93 | return `${adjective}-${noun}` |
| 94 | } |
| 95 | |
| 96 | // Convert invalid function name to valid one |
| 97 | const sanitizeFunctionName = (name: string): string => { |
| 98 | // Replace invalid characters with hyphens |
| 99 | return name.replace(/[^A-Za-z0-9_-]/g, '-') |
| 100 | } |
| 101 | |
| 102 | // Type for the form values |
| 103 | type FormValues = z.infer<typeof FormSchema> |
| 104 | |
| 105 | const INITIAL_FILES: FileData[] = [ |
| 106 | { |
| 107 | id: 1, |
| 108 | name: 'index.ts', |
| 109 | content: EDGE_FUNCTION_TEMPLATES[0].content, |
| 110 | state: 'new', |
| 111 | }, |
| 112 | ] |
| 113 | |
| 114 | const NewFunctionPage = () => { |
| 115 | const router = useRouter() |
| 116 | const { ref, template } = useParams() |
| 117 | const { data: project } = useSelectedProjectQuery() |
| 118 | const { data: org } = useSelectedOrganizationQuery() |
| 119 | const snap = useAiAssistantStateSnapshot() |
| 120 | const { mutate: sendEvent } = useSendEventMutation() |
| 121 | const showStripeExample = useIsFeatureEnabled('edge_functions:show_stripe_example') |
| 122 | const { openSidebar } = useSidebarManagerSnapshot() |
| 123 | |
| 124 | const [files, setFiles] = useState<FileData[]>(INITIAL_FILES) |
| 125 | const [selectedFileId, setSelectedFileId] = useState<number>(INITIAL_FILES[0].id) |
| 126 | const [open, setOpen] = useState(false) |
| 127 | const templatesListboxId = useId() |
| 128 | const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false) |
| 129 | const [savedCode, setSavedCode] = useState<string>('') |
| 130 | |
| 131 | const templates = useMemo(() => { |
| 132 | if (showStripeExample) { |
| 133 | return EDGE_FUNCTION_TEMPLATES |
| 134 | } |
| 135 | // Filter out Stripe template |
| 136 | return EDGE_FUNCTION_TEMPLATES.filter((template) => template.value !== 'stripe-webhook') |
| 137 | }, [showStripeExample]) |
| 138 | |
| 139 | const form = useForm<FormValues>({ |
| 140 | resolver: zodResolver(FormSchema as any), |
| 141 | defaultValues: { |
| 142 | functionName: generateRandomFunctionName(), |
| 143 | }, |
| 144 | }) |
| 145 | |
| 146 | const { |
| 147 | mutate: deployFunction, |
| 148 | isPending: isDeploying, |
| 149 | isSuccess: hasDeployed, |
| 150 | } = useEdgeFunctionDeployMutation({ |
| 151 | // [Joshen] To investigate: For some reason, the invalidation for list of edge functions isn't triggering |
| 152 | onSuccess: () => { |
| 153 | toast.success('Successfully deployed edge function') |
| 154 | const functionName = form.getValues('functionName') |
| 155 | // Allow the mutation state (isSuccess) to propagate before navigating |
| 156 | // to prevent unnecessary dialog about unsaved changes |
| 157 | setTimeout(() => { |
| 158 | if (ref && functionName) { |
| 159 | router.push(`/project/${ref}/functions/${functionName}/details`) |
| 160 | } |
| 161 | }, 150) |
| 162 | }, |
| 163 | }) |
| 164 | |
| 165 | const onSubmit = (values: FormValues) => { |
| 166 | if (isDeploying || !ref) return |
| 167 | |
| 168 | deployFunction({ |
| 169 | projectRef: ref, |
| 170 | slug: values.functionName, |
| 171 | metadata: { name: values.functionName, verify_jwt: true }, |
| 172 | files: files.map(({ name, content }) => ({ name, content })), |
| 173 | }) |
| 174 | |
| 175 | sendEvent({ |
| 176 | action: 'edge_function_deploy_button_clicked', |
| 177 | properties: { origin: 'functions_editor' }, |
| 178 | groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, |
| 179 | }) |
| 180 | } |
| 181 | |
| 182 | const handleChat = () => { |
| 183 | const selectedFile = files.find((f) => f.id === selectedFileId) |
| 184 | |
| 185 | openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) |
| 186 | snap.newChat({ |
| 187 | name: 'Explain edge function', |
| 188 | sqlSnippets: [selectedFile?.content ?? ''], |
| 189 | initialInput: 'Help me understand and improve this edge function...', |
| 190 | suggestions: { |
| 191 | title: |
| 192 | 'I can help you understand and improve your edge function. Here are a few example prompts to get you started:', |
| 193 | prompts: [ |
| 194 | { |
| 195 | label: 'Explain Function', |
| 196 | description: 'Explain what this function does...', |
| 197 | }, |
| 198 | { |
| 199 | label: 'Optimize Function', |
| 200 | description: 'Help me optimize this function...', |
| 201 | }, |
| 202 | { |
| 203 | label: 'Add Features', |
| 204 | description: 'Show me how to add more features...', |
| 205 | }, |
| 206 | { |
| 207 | label: 'Error Handling', |
| 208 | description: 'Help me handle errors better...', |
| 209 | }, |
| 210 | ], |
| 211 | }, |
| 212 | }) |
| 213 | sendEvent({ |
| 214 | action: 'edge_function_ai_assistant_button_clicked', |
| 215 | properties: { origin: 'functions_editor_chat' }, |
| 216 | groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, |
| 217 | }) |
| 218 | } |
| 219 | |
| 220 | const onSelectTemplate = (templateValue: string) => { |
| 221 | const template = EDGE_FUNCTION_TEMPLATES.find((t) => t.value === templateValue) |
| 222 | if (template) { |
| 223 | setFiles((prev) => |
| 224 | prev.map((file) => |
| 225 | file.id === selectedFileId ? { ...file, content: template.content } : file |
| 226 | ) |
| 227 | ) |
| 228 | setOpen(false) |
| 229 | sendEvent({ |
| 230 | action: 'edge_function_template_clicked', |
| 231 | properties: { templateName: template.name, origin: 'editor_page' }, |
| 232 | groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, |
| 233 | }) |
| 234 | } |
| 235 | setIsPreviewingTemplate(false) |
| 236 | } |
| 237 | |
| 238 | const handleTemplateMouseEnter = (content: string) => { |
| 239 | if (!isPreviewingTemplate) { |
| 240 | const selectedFile = files.find((f) => f.id === selectedFileId) ?? files[0] |
| 241 | setSavedCode(selectedFile.content) |
| 242 | } |
| 243 | setIsPreviewingTemplate(true) |
| 244 | setFiles((prev) => prev.map((f) => (f.id === selectedFileId ? { ...f, content } : f))) |
| 245 | } |
| 246 | |
| 247 | const handleTemplateMouseLeave = () => { |
| 248 | if (isPreviewingTemplate) { |
| 249 | setIsPreviewingTemplate(false) |
| 250 | setFiles((prev) => |
| 251 | prev.map((f) => (f.id === selectedFileId ? { ...f, content: savedCode } : f)) |
| 252 | ) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // Try to sanitize function name when it's invalid |
| 257 | const handleDeploy = () => { |
| 258 | const currentName = form.getValues('functionName') |
| 259 | const isValid = FUNCTION_NAME_REGEX.test(currentName) |
| 260 | |
| 261 | if (!isValid && currentName) { |
| 262 | const sanitizedName = sanitizeFunctionName(currentName) |
| 263 | form.setValue('functionName', sanitizedName, { shouldValidate: true }) |
| 264 | } |
| 265 | |
| 266 | form.handleSubmit(onSubmit)() |
| 267 | } |
| 268 | |
| 269 | useEffect(() => { |
| 270 | if (template) { |
| 271 | const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template) |
| 272 | if (templateMeta) { |
| 273 | form.reset({ functionName: template }) |
| 274 | setSelectedFileId(1) |
| 275 | setFiles([ |
| 276 | { |
| 277 | id: 1, |
| 278 | name: 'index.ts', |
| 279 | content: templateMeta.content, |
| 280 | state: 'new', |
| 281 | }, |
| 282 | ]) |
| 283 | } |
| 284 | } |
| 285 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 286 | }, [template]) |
| 287 | |
| 288 | const hasUnsavedChanges = useMemo(() => !isEqual(INITIAL_FILES, files), [files]) |
| 289 | |
| 290 | return ( |
| 291 | <PageLayout |
| 292 | size="full" |
| 293 | isCompact |
| 294 | title="Create new edge function" |
| 295 | breadcrumbs={[ |
| 296 | { |
| 297 | label: 'Edge Functions', |
| 298 | href: `/project/${ref}/functions`, |
| 299 | }, |
| 300 | ]} |
| 301 | primaryActions={ |
| 302 | <> |
| 303 | <Popover open={open} onOpenChange={setOpen}> |
| 304 | <PopoverTrigger asChild> |
| 305 | <Button |
| 306 | size="tiny" |
| 307 | type="default" |
| 308 | role="combobox" |
| 309 | aria-expanded={open} |
| 310 | aria-controls={templatesListboxId} |
| 311 | icon={<Book size={14} />} |
| 312 | > |
| 313 | Templates |
| 314 | </Button> |
| 315 | </PopoverTrigger> |
| 316 | <PopoverContent id={templatesListboxId} className="w-[300px] p-0" align="end"> |
| 317 | <Command> |
| 318 | <CommandInput placeholder="Search templates..." /> |
| 319 | <CommandList> |
| 320 | <CommandEmpty>No templates found.</CommandEmpty> |
| 321 | <CommandGroup> |
| 322 | {templates.map((template) => ( |
| 323 | <CommandItem |
| 324 | key={template.value} |
| 325 | value={template.value} |
| 326 | onSelect={onSelectTemplate} |
| 327 | onMouseEnter={() => handleTemplateMouseEnter(template.content)} |
| 328 | onMouseLeave={handleTemplateMouseLeave} |
| 329 | className="cursor-pointer" |
| 330 | > |
| 331 | <div className="flex flex-col gap-1"> |
| 332 | <div className="flex items-center"> |
| 333 | <Check |
| 334 | className={cn( |
| 335 | 'mr-2 h-4 w-4', |
| 336 | files.some((f) => f.content === template.content) |
| 337 | ? 'opacity-100' |
| 338 | : 'opacity-0' |
| 339 | )} |
| 340 | /> |
| 341 | <span className="text-foreground">{template.name}</span> |
| 342 | </div> |
| 343 | <span className="text-xs text-foreground-light pl-6"> |
| 344 | {template.description} |
| 345 | </span> |
| 346 | </div> |
| 347 | </CommandItem> |
| 348 | ))} |
| 349 | </CommandGroup> |
| 350 | </CommandList> |
| 351 | </Command> |
| 352 | </PopoverContent> |
| 353 | </Popover> |
| 354 | <Button |
| 355 | size="tiny" |
| 356 | type="default" |
| 357 | onClick={handleChat} |
| 358 | icon={<AiIconAnimation size={16} />} |
| 359 | > |
| 360 | Chat |
| 361 | </Button> |
| 362 | </> |
| 363 | } |
| 364 | > |
| 365 | <FileExplorerAndEditor |
| 366 | files={files} |
| 367 | onFilesChange={setFiles} |
| 368 | aiEndpoint={`${BASE_PATH}/api/ai/code/complete`} |
| 369 | aiMetadata={{ |
| 370 | projectRef: project?.ref, |
| 371 | connectionString: project?.connectionString, |
| 372 | orgSlug: org?.slug, |
| 373 | }} |
| 374 | selectedFileId={selectedFileId} |
| 375 | setSelectedFileId={setSelectedFileId} |
| 376 | /> |
| 377 | |
| 378 | <Form {...form}> |
| 379 | <form |
| 380 | onSubmit={form.handleSubmit(onSubmit)} |
| 381 | className="flex items-center bg-background-muted justify-end p-4 border-t bg-surface-100 gap-3" |
| 382 | > |
| 383 | <div className="flex items-center gap-3"> |
| 384 | <Label htmlFor="functionName">Function name</Label> |
| 385 | <FormField |
| 386 | control={form.control} |
| 387 | name="functionName" |
| 388 | render={({ field }) => ( |
| 389 | <FormItem className="flex flex-col gap-0 m-0"> |
| 390 | <div className="flex items-center"> |
| 391 | <FormControl> |
| 392 | <Input |
| 393 | id="functionName" |
| 394 | type="text" |
| 395 | size={'large'} |
| 396 | placeholder="Give your function a name..." |
| 397 | className="w-[250px]" |
| 398 | {...field} |
| 399 | /> |
| 400 | </FormControl> |
| 401 | {form.formState.errors.functionName && ( |
| 402 | <Tooltip> |
| 403 | <TooltipTrigger> |
| 404 | <AlertCircle className="w-4 h-4 text-destructive ml-2" /> |
| 405 | </TooltipTrigger> |
| 406 | <TooltipContent> |
| 407 | {form.formState.errors.functionName.message} |
| 408 | </TooltipContent> |
| 409 | </Tooltip> |
| 410 | )} |
| 411 | </div> |
| 412 | </FormItem> |
| 413 | )} |
| 414 | /> |
| 415 | </div> |
| 416 | <Button |
| 417 | loading={isDeploying} |
| 418 | size="medium" |
| 419 | disabled={files.length === 0 || isDeploying} |
| 420 | onClick={handleDeploy} |
| 421 | > |
| 422 | Deploy function |
| 423 | </Button> |
| 424 | </form> |
| 425 | </Form> |
| 426 | <PreventNavigationOnUnsavedChanges hasChanges={hasUnsavedChanges && !hasDeployed} /> |
| 427 | </PageLayout> |
| 428 | ) |
| 429 | } |
| 430 | |
| 431 | NewFunctionPage.getLayout = (page: React.ReactNode) => { |
| 432 | return ( |
| 433 | <DefaultLayout> |
| 434 | <EdgeFunctionsLayout title="New">{page}</EdgeFunctionsLayout> |
| 435 | </DefaultLayout> |
| 436 | ) |
| 437 | } |
| 438 | |
| 439 | export default NewFunctionPage |