new.tsx439 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { useParams } from 'common'
4import { isEqual } from 'lodash'
5import { AlertCircle, Book, Check } from 'lucide-react'
6import { useRouter } from 'next/router'
7import { useEffect, useId, useMemo, useState } from 'react'
8import { useForm } from 'react-hook-form'
9import { toast } from 'sonner'
10import {
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'
33import * as z from 'zod'
34
35import { EDGE_FUNCTION_TEMPLATES } from '@/components/interfaces/Functions/Functions.templates'
36import { DefaultLayout } from '@/components/layouts/DefaultLayout'
37import EdgeFunctionsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
38import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
39import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
40import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
41import { FileExplorerAndEditor } from '@/components/ui/FileExplorerAndEditor'
42import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types'
43import { useEdgeFunctionDeployMutation } from '@/data/edge-functions/edge-functions-deploy-mutation'
44import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
45import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
46import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
47import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
48import { BASE_PATH } from '@/lib/constants'
49import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
50import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
51
52// Array of adjectives and nouns for random function name generation
53const ADJECTIVES = [
54 'quick',
55 'clever',
56 'bright',
57 'swift',
58 'rapid',
59 'smart',
60 'smooth',
61 'dynamic',
62 'super',
63 'hyper',
64]
65const 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
79const FUNCTION_NAME_REGEX = /^[A-Za-z0-9_-]+$/
80
81// Define form schema with zod
82const 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
90const 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
97const 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
103type FormValues = z.infer<typeof FormSchema>
104
105const INITIAL_FILES: FileData[] = [
106 {
107 id: 1,
108 name: 'index.ts',
109 content: EDGE_FUNCTION_TEMPLATES[0].content,
110 state: 'new',
111 },
112]
113
114const 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
431NewFunctionPage.getLayout = (page: React.ReactNode) => {
432 return (
433 <DefaultLayout>
434 <EdgeFunctionsLayout title="New">{page}</EdgeFunctionsLayout>
435 </DefaultLayout>
436 )
437}
438
439export default NewFunctionPage