Edge functions empty state (#33802)

* page components

* page component changes

* settings but broken saving

* rvert

* use sheet for provider

* styling

* remove things

* Some refactoring and fixing, added JSDocs to layouts

* Smol refactor

* Fix

* Update JSDocs

* updated scaffolding

* update edge functions layout

* remove params

* Clean up

* Spelling

* Clean up FormFieldWrappers

* One last clean up

* create edge functon

* use component

* flag

* fix merge errors

* fix imports

* fix merge issues

* fix import

* fix flag

* remove unused

* simple empty

* empty state improvements

* move templates

* re-add flag

* use templates

* flag

* copy functions

* feature flag it

* Fix issues and clean up

* Smol fix

* Add comment

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Saxon FletcherandJoshen Lim authored and GitHub committed 2025-02-28 01:15:59 +08:00
1 parent a3fee4e76a
commit 593625c574
13 files changed
+422 -107

No files matched your search

@@ -52,7 +52,7 @@ const FormSchema = z.object({
verify_jwt: z.boolean(),
})
const EdgeFunctionDetails = () => {
export const EdgeFunctionDetails = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
const [showDeleteModal, setShowDeleteModal] = useState(false)
@@ -365,5 +365,3 @@ const EdgeFunctionDetails = () => {
</div>
)
}
export default EdgeFunctionDetails
@@ -14,7 +14,7 @@ interface EdgeFunctionsListItemProps {
function: EdgeFunctionsResponse
}
const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) => {
export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) => {
const router = useRouter()
const { ref } = useParams()
const { project } = useProjectContext()
@@ -46,7 +46,9 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
</Table.td>
<Table.td>
<div className="text-xs text-foreground-light flex gap-2 items-center truncate">
<p className="font-mono truncate hidden md:inline">{endpoint}</p>
<p title={endpoint} className="font-mono truncate hidden md:inline max-w-[30rem]">
{endpoint}
</p>
<button
type="button"
className="text-foreground-lighter hover:text-foreground transition"
@@ -99,5 +101,3 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
</Table.tr>
)
}
export default EdgeFunctionsListItem
@@ -52,6 +52,37 @@ Deno.serve(async (req) => {
} catch (err) {
return new Response(String(err?.message ?? err), { status: 500 })
}
})`,
},
{
value: 'storage-upload',
name: 'Supabase Storage Upload',
description: 'Upload files to Supabase Storage',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from 'jsr:@supabase/supabase-js@2'
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
)
Deno.serve(async (req) => {
const formData = await req.formData()
const file = formData.get('file')
const { data, error } = await supabase
.storage
.from('your-bucket')
.upload(
\`files/\${crypto.randomUUID()}\`,
file,
{ contentType: file.type }
)
if (error) throw error
return new Response(
JSON.stringify({ data }),
{ headers: { 'Content-Type': 'application/json' }}
)
})`,
},
{
@@ -95,4 +126,177 @@ app.get(/(.*)/, (req, res) => {
app.listen(8000);`,
},
{
value: 'openai-completion',
name: 'OpenAI Text Completion',
description: 'Generate text completions using OpenAI GPT-3',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { Configuration, OpenAIApi } from 'npm:openai@3.3.0'
const openAi = new OpenAIApi(
new Configuration({
apiKey: Deno.env.get('OPENAI_API_KEY')
})
)
Deno.serve(async (req) => {
const { prompt } = await req.json()
const completion = await openAi.createCompletion({
model: 'text-davinci-003',
prompt,
max_tokens: 200
})
return new Response(
JSON.stringify({ text: completion.data.choices[0].text }),
{ headers: { 'Content-Type': 'application/json' }}
)
})`,
},
{
value: 'stripe-webhook',
name: 'Stripe Webhook Example',
description: 'Handle Stripe webhook events securely',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import Stripe from 'npm:stripe@12.0.0'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY')!)
const endpointSecret = Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!
Deno.serve(async (req) => {
const signature = req.headers.get('stripe-signature')
try {
const event = stripe.webhooks.constructEvent(
await req.text(),
signature!,
endpointSecret
)
switch (event.type) {
case 'payment_intent.succeeded':
// Handle successful payment
break
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
})
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 400 }
)
}
})`,
},
{
value: 'resend-email',
name: 'Send Emails',
description: 'Send emails using the Resend API',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!
Deno.serve(async (req) => {
const { to, subject, html } = await req.json()
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: \`Bearer \${RESEND_API_KEY}\`,
},
body: JSON.stringify({
from: 'you@example.com',
to,
subject,
html,
}),
})
const data = await res.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
})
})`,
},
{
value: 'image-transform',
name: 'Image Transformation',
description: 'Transform images using ImageMagick WASM',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import {
ImageMagick,
initializeImageMagick,
} from "npm:@imagemagick/magick-wasm@0.0.30"
await initializeImageMagick()
Deno.serve(async (req) => {
const formData = await req.formData()
const file = formData.get('file')
const content = await file.arrayBuffer()
const result = await ImageMagick.read(new Uint8Array(content), (img) => {
img.resize(500, 300)
img.blur(60, 5)
return img.write(data => data)
})
return new Response(
result,
{ headers: { 'Content-Type': 'image/png' }}
)
})`,
},
{
value: 'discord-bot',
name: 'Discord Bot Example',
description: 'Build a Slash Command Discord Bot',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { verifyDiscordRequest } from './_shared/discord.ts'
Deno.serve(async (req) => {
const { valid } = await verifyDiscordRequest(req)
if (!valid) {
return new Response('Invalid request', { status: 401 })
}
const message = await req.json()
if (message.type === 1) {
return new Response(
JSON.stringify({ type: 1 }),
{ headers: { 'Content-Type': 'application/json' }}
)
}
return new Response(
JSON.stringify({
type: 4,
data: { content: 'Hello from Supabase Edge Functions!' }
}),
{ headers: { 'Content-Type': 'application/json' }}
)
})`,
},
{
value: 'websocket-server',
name: 'Websocket Server Example',
description: 'Create a real-time WebSocket server',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
Deno.serve((req) => {
const upgrade = req.headers.get("upgrade") || ""
if (upgrade.toLowerCase() != "websocket") {
return new Response("request isn't trying to upgrade to websocket.")
}
const { socket, response } = Deno.upgradeWebSocket(req)
socket.onopen = () => {
console.log("client connected!")
socket.send('Welcome to Supabase Edge Functions!')
}
socket.onmessage = (e) => {
console.log("client sent message:", e.data)
socket.send(new Date().toString())
}
return response
})`,
},
]
@@ -1,19 +1,132 @@
import { ExternalLink } from 'lucide-react'
import { useParams } from 'common'
import { ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import { ResourceItem } from 'components/ui/Resource/ResourceItem'
import { ResourceList } from 'components/ui/Resource/ResourceList'
import { useFlag } from 'hooks/ui/useFlag'
import { Code, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useAppStateSnapshot } from 'state/app-state'
import {
AiIconAnimation,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogSection,
DialogTrigger,
} from 'ui'
import { EDGE_FUNCTION_TEMPLATES } from './Functions.templates'
import { TerminalInstructions } from './TerminalInstructions'
import { DocsButton } from 'components/ui/DocsButton'
import { Button } from 'ui'
import TerminalInstructions from './TerminalInstructions'
export const FunctionsEmptyState = () => {
const { ref } = useParams()
const router = useRouter()
const { setAiAssistantPanel } = useAppStateSnapshot()
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const FunctionsEmptyState = () => {
return (
<>
<div className="w-full mt-8">
<div className="col-span-8 bg-surface-100 px-5 py-4 border rounded-md">
<TerminalInstructions />
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Create your first edge function</CardTitle>
</CardHeader>
<CardContent className="p-0 grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] divide-y md:divide-y-0 md:divide-x divide-default items-stretch">
{/* CLI Option */}
<div className="p-8">
<div className="flex items-center gap-2">
<Terminal strokeWidth={1.5} size={20} />
<h4 className="text-base text-foreground">Via CLI</h4>
</div>
<p className="text-sm text-foreground-light mb-4 mt-1">
Create and deploy functions using the Supabase CLI. Ideal for local development and
version control.
</p>
<Dialog>
<DialogTrigger asChild>
<Button type="default">View CLI Instructions</Button>
</DialogTrigger>
<DialogContent size="large">
<DialogSection padding="small">
<TerminalInstructions />
</DialogSection>
</DialogContent>
</Dialog>
</div>
{/* AI Assistant Option */}
<div className="p-8">
<div className="flex items-center gap-2">
<AiIconAnimation size={20} />
<h4 className="text-base text-foreground">AI Assistant</h4>
</div>
<p className="text-sm text-foreground-light mb-4 mt-1">
Let our AI assistant help you create functions. Perfect for kickstarting a function.
</p>
<Button
type="default"
onClick={() =>
setAiAssistantPanel({
open: true,
initialInput: 'Create a new edge function that ...',
suggestions: {
title:
'I can help you create a new edge function. Here are a few example prompts to get you started:',
prompts: [
'Create a new edge function that processes payments with Stripe',
'Create a new edge function that sends emails with Resend',
'Create a new edge function that generates PDFs from HTML templates',
],
},
})
}
>
Open Assistant
</Button>
</div>
{/* Editor Option */}
{edgeFunctionCreate && (
<div className="p-8">
<div className="flex items-center gap-2">
<Code strokeWidth={1.5} size={20} />
<h4 className="text-base text-foreground">Via Editor</h4>
</div>
<p className="text-sm text-foreground-light mb-4 mt-1">
Create and edit functions directly in the browser. Download to local at any time.
</p>
<Button type="default" onClick={() => router.push(`/project/${ref}/functions/new`)}>
Open Editor
</Button>
</div>
)}
</CardContent>
</Card>
{edgeFunctionCreate && (
<>
<ScaffoldSectionTitle className="text-xl mb-4 mt-12">
Start with a template
</ScaffoldSectionTitle>
<ResourceList>
{EDGE_FUNCTION_TEMPLATES.map((template) => (
<ResourceItem
key={template.name}
media={<Code strokeWidth={1.5} size={16} className="-translate-y-[9px]" />}
onClick={() => {}}
>
<Link href={`/project/${ref}/functions/new?template=${template.value}`}>
<p>{template.name}</p>
<p className="text-sm text-foreground-lighter">{template.description}</p>
</Link>
</ResourceItem>
))}
</ResourceList>
</>
)}
</>
)
}
export default FunctionsEmptyState
@@ -21,7 +21,7 @@ interface TerminalInstructionsProps extends ComponentPropsWithoutRef<typeof Coll
removeBorder?: boolean
}
const TerminalInstructions = forwardRef<
export const TerminalInstructions = forwardRef<
ElementRef<typeof Collapsible_Shadcn_>,
TerminalInstructionsProps
>(({ closable = false, removeBorder = false, ...props }, ref) => {
@@ -158,5 +158,3 @@ const TerminalInstructions = forwardRef<
})
TerminalInstructions.displayName = 'TerminalInstructions'
export default TerminalInstructions
@@ -1,4 +0,0 @@
export { default as EdgeFunctionDetails } from './EdgeFunctionDetails/EdgeFunctionDetails'
export { default as EdgeFunctionsListItem } from './EdgeFunctionsListItem'
export { default as FunctionsEmptyState } from './FunctionsEmptyState'
export { default as TerminalInstructions } from './TerminalInstructions'
@@ -13,9 +13,7 @@ const EdgeFunctionsProductMenu = () => {
const menuItems = [
{
name: 'Edge Functions',
key: 'functions',
url: `/project/${projectRef}/functions`,
title: 'Manage',
items: [
{
name: 'Functions',
@@ -2,6 +2,7 @@ import { useParams } from 'common'
import { ChevronLeft } from 'lucide-react'
import { Fragment, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from 'ui'
import {
Breadcrumb,
@@ -59,14 +60,13 @@ export const PageHeader = ({
{item.element ? (
item.element
) : item.href ? (
<BreadcrumbLink
className="flex items-center gap-2"
href={!!ref ? item.href.replace('[ref]', ref) : item.href}
>
{breadcrumbs.length === 1 && !isCompact && (
<ChevronLeft size={16} strokeWidth={1.5} />
)}
{item.label}
<BreadcrumbLink asChild className="flex items-center gap-2">
<Link href={!!ref ? item.href.replace('[ref]', ref) : item.href}>
{breadcrumbs.length === 1 && !isCompact && (
<ChevronLeft size={16} strokeWidth={1.5} />
)}
{item.label}
</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPageItem className="flex items-center gap-2">
@@ -1,16 +1,17 @@
import React from 'react'
import { Edit, File, Plus, Trash } from 'lucide-react'
import { Button } from 'ui'
import { useEffect, useState } from 'react'
import AIEditor from 'components/ui/AIEditor'
import {
TreeView,
TreeViewItem,
flattenTree,
Button,
ContextMenu_Shadcn_,
ContextMenuTrigger_Shadcn_,
ContextMenuContent_Shadcn_,
ContextMenuItem_Shadcn_,
ContextMenuSeparator_Shadcn_,
ContextMenuTrigger_Shadcn_,
flattenTree,
TreeView,
TreeViewItem,
} from 'ui'
interface FileData {
@@ -61,6 +62,18 @@ const FileExplorerAndEditor = ({
}: FileExplorerAndEditorProps) => {
const selectedFile = files.find((f) => f.selected) ?? files[0]
const [treeData, setTreeData] = useState({
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: false,
originalId: file.id,
},
})),
})
const handleChange = (value: string) => {
const updatedFiles = files.map((file) =>
file.id === selectedFile.id ? { ...file, content: value } : file
@@ -132,20 +145,8 @@ const FileExplorerAndEditor = ({
setTreeData(updatedTreeData)
}
const [treeData, setTreeData] = React.useState({
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: false,
originalId: file.id,
},
})),
})
// Update treeData when files change
React.useEffect(() => {
useEffect(() => {
setTreeData({
name: '',
children: files.map((file) => ({
@@ -1,10 +1,10 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { components } from 'api-types'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { edgeFunctionsKeys } from './keys'
import { components } from 'api-types'
export type EdgeFunctionsDeployVariables = {
projectRef: string
@@ -61,7 +61,7 @@ export const useEdgeFunctionDeployMutation = ({
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef))])
await queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -1,4 +1,4 @@
import { EdgeFunctionDetails } from 'components/interfaces/Functions'
import { EdgeFunctionDetails } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
import type { NextPageWithLayout } from 'types'
@@ -2,15 +2,13 @@ import { ChevronDown, Code, ExternalLink, Terminal } from 'lucide-react'
import { useRouter } from 'next/router'
import { useParams } from 'common'
import {
EdgeFunctionsListItem,
FunctionsEmptyState,
TerminalInstructions,
} from 'components/interfaces/Functions'
import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem'
import { FunctionsEmptyState } from 'components/interfaces/Functions/FunctionsEmptyState'
import { TerminalInstructions } from 'components/interfaces/Functions/TerminalInstructions'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer } from 'components/layouts/Scaffold'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
@@ -93,20 +91,16 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
)
const secondaryActions = [
...(!hasFunctions
? [
<Button asChild key="edge-function-examples" type="default" icon={<ExternalLink />}>
<a
target="_blank"
rel="noreferrer"
href="https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions"
>
Examples
</a>
</Button>,
]
: []),
<DocsButton key="docs" href="https://supabase.com/docs/guides/functions" />,
<Button asChild key="edge-function-examples" type="default" icon={<ExternalLink />}>
<a
target="_blank"
rel="noreferrer"
href="https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions"
>
Examples
</a>
</Button>,
<ButtonTooltip
key="edge-function-create"
type="default"
@@ -145,18 +139,14 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
secondaryActions={secondaryActions}
>
<ScaffoldContainer size="large">
{isLoading && (
<div className="pt-8">
<GenericSkeletonLoader />
</div>
)}
<ScaffoldSection isFullWidth>
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
{isSuccess && (
<>
{hasFunctions ? (
<div className="py-6 space-y-4">
{isSuccess && (
<>
{hasFunctions ? (
<Table
head={
<>
@@ -176,12 +166,12 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
</>
}
/>
</div>
) : (
<FunctionsEmptyState />
)}
</>
)}
) : (
<FunctionsEmptyState />
)}
</>
)}
</ScaffoldSection>
</ScaffoldContainer>
</PageLayout>
)
@@ -1,10 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle, Book, Check } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { EDGE_FUNCTION_TEMPLATES } from 'components/interfaces/Functions/Functions.templates'
@@ -41,7 +41,6 @@ import {
TooltipContent,
TooltipTrigger,
} from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
// Array of adjectives and nouns for random function name generation
const ADJECTIVES = [
@@ -73,11 +72,11 @@ const NOUNS = [
const FUNCTION_NAME_REGEX = /^[A-Za-z0-9_-]+$/
// Define form schema with yup
const formSchema = yup.object({
functionName: yup
const FormSchema = z.object({
functionName: z
.string()
.required('Function name is required')
.matches(FUNCTION_NAME_REGEX, 'Only letters, numbers, hyphens, and underscores allowed'),
.min(1, 'Function name is required')
.regex(FUNCTION_NAME_REGEX, 'Only letters, numbers, hyphens, and underscores allowed'),
})
// Generate a random function name
@@ -94,11 +93,11 @@ const sanitizeFunctionName = (name: string): string => {
}
// Type for the form values
type FormValues = yup.InferType<typeof formSchema>
type FormValues = z.infer<typeof FormSchema>
const NewFunctionPage = () => {
const router = useRouter()
const { ref } = useParams()
const { ref, template } = useParams()
const project = useSelectedProject()
const isOptedInToAI = useOrgOptedIntoAi()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
@@ -119,15 +118,15 @@ const NewFunctionPage = () => {
const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
const [savedCode, setSavedCode] = useState<string>('')
// Setup form with react-hook-form and yup resolver
const form = useForm<FormValues>({
resolver: yupResolver(formSchema),
resolver: zodResolver(FormSchema),
defaultValues: {
functionName: generateRandomFunctionName(),
},
})
const { mutate: deployFunction, isLoading: isDeploying } = useEdgeFunctionDeployMutation({
// [Joshen] To investigate: For some reason, the invalidation for list of edge functions isn't triggering
onSuccess: () => {
toast.success('Successfully deployed edge function')
const functionName = form.getValues('functionName')
@@ -218,6 +217,24 @@ const NewFunctionPage = () => {
}
}, [edgeFunctionCreate, ref, router])
useEffect(() => {
if (template) {
const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template)
if (templateMeta) {
form.reset({ functionName: template })
setFiles([
{
id: 1,
name: 'index.ts',
selected: true,
content: templateMeta.content,
},
])
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [template])
return (
<PageLayout
size="full"
@@ -243,7 +260,7 @@ const NewFunctionPage = () => {
Templates
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[300px] p-0">
<PopoverContent_Shadcn_ className="w-[300px] p-0" align="end">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search templates..." />
<CommandList_Shadcn_>