Files
supabase/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx
2025-03-26 19:54:12 +10:00

385 lines
15 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import dayjs from 'dayjs'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation'
import { useEdgeFunctionUpdateMutation } from 'data/edge-functions/edge-functions-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
cn,
CodeBlock,
CriticalIcon,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
Switch,
Tabs_Shadcn_ as Tabs,
TabsContent_Shadcn_ as TabsContent,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import CommandRender from '../CommandRender'
import { INVOCATION_TABS } from './EdgeFunctionDetails.constants'
import { generateCLICommands } from './EdgeFunctionDetails.utils'
const FormSchema = z.object({
name: z.string().min(0, 'Name is required'),
verify_jwt: z.boolean(),
})
export const EdgeFunctionDetails = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
const {
data: selectedFunction,
error,
isLoading,
isError,
isSuccess,
} = useEdgeFunctionQuery({
projectRef,
slug: functionSlug,
})
const { mutate: updateEdgeFunction, isLoading: isUpdating } = useEdgeFunctionUpdateMutation()
const { mutate: deleteEdgeFunction, isLoading: isDeleting } = useEdgeFunctionDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted "${selectedFunction?.name}"`)
router.push(`/project/${projectRef}/functions`)
},
})
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: { name: '', verify_jwt: false },
})
const { anonKey } = getAPIKeys(settings)
const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]'
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint ?? ''
const functionUrl =
customDomainData?.customDomain?.status === 'active'
? `https://${customDomainData.customDomain.hostname}/functions/v1/${selectedFunction?.slug}`
: `${protocol}://${endpoint}/functions/v1/${selectedFunction?.slug}`
const hasImportMap = useMemo(
() => selectedFunction?.import_map || selectedFunction?.import_map_path,
[selectedFunction]
)
const { managementCommands } = generateCLICommands({
selectedFunction,
functionUrl,
anonKey: apiKey,
})
const onUpdateFunction: SubmitHandler<z.infer<typeof FormSchema>> = async (values: any) => {
if (!projectRef) return console.error('Project ref is required')
if (selectedFunction === undefined) return console.error('No edge function selected')
updateEdgeFunction(
{
projectRef,
slug: selectedFunction.slug,
payload: values,
},
{
onSuccess: () => {
toast.success(`Successfully updated edge function`)
},
}
)
}
const onConfirmDelete = async () => {
if (!projectRef) return console.error('Project ref is required')
if (selectedFunction === undefined) return console.error('No edge function selected')
deleteEdgeFunction({ projectRef, slug: selectedFunction.slug })
}
useEffect(() => {
if (selectedFunction) {
form.reset({
name: selectedFunction.name,
verify_jwt: selectedFunction.verify_jwt,
})
}
}, [selectedFunction])
return (
<div className="mx-auto flex flex-col-reverse 2xl:flex-row gap-8 pb-8">
<div className="flex-1 min-w-0 overflow-hidden">
<ScaffoldSection isFullWidth className="!pt-0 2xl:first:!pt-12">
<ScaffoldSectionTitle className="mb-4">Function Configuration</ScaffoldSectionTitle>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onUpdateFunction)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
label="Name"
layout="flex-row-reverse"
description="Your slug and endpoint URL will remain the same"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
className="w-64"
disabled={!canUpdateEdgeFunction}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="verify_jwt"
render={({ field }) => (
<FormItemLayout
label="Enforce JWT Verification"
layout="flex-row-reverse"
description="Require a valid JWT in the authorization header when invoking the function"
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateEdgeFunction}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!canUpdateEdgeFunction || !form.formState.isDirty}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Invoke function</ScaffoldSectionTitle>
<Card>
<CardContent>
<Tabs defaultValue="curl" className="w-full">
<TabsList className="flex flex-wrap gap-4">
{INVOCATION_TABS.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{INVOCATION_TABS.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4">
<div className="overflow-x-auto">
<CodeBlock
language={tab.language}
hideLineNumbers={tab.hideLineNumbers}
className="p-0 text-xs !mt-0 border-none"
value={tab.code(functionUrl, selectedFunction?.name ?? '', apiKey)}
/>
</div>
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Develop locally</ScaffoldSectionTitle>
<div className="rounded border bg-surface-100 px-6 py-4 drop-shadow-sm">
<div className="space-y-6">
<CommandRender
commands={[
{
command: `supabase functions download ${selectedFunction?.slug}`,
description: 'Download the function to your local machine',
jsx: () => (
<>
<span className="text-brand-600">supabase</span> functions download{' '}
{selectedFunction?.slug}
</>
),
comment: '1. Download the function',
},
]}
/>
<CommandRender commands={[managementCommands[0]]} />
<CommandRender commands={[managementCommands[1]]} />
</div>
</div>
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Delete function</ScaffoldSectionTitle>
<Alert_Shadcn_ variant="destructive">
<CriticalIcon />
<AlertTitle_Shadcn_>
Once your function is deleted, it can no longer be restored
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Make sure you have made a backup if you want to restore your edge function
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<Button
type="danger"
disabled={!canUpdateEdgeFunction}
loading={selectedFunction?.id === undefined}
onClick={() => setShowDeleteModal(true)}
>
Delete edge function
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</ScaffoldSection>
<ConfirmationModal
visible={showDeleteModal}
loading={isDeleting}
variant="destructive"
confirmLabel="Delete"
confirmLabelLoading="Deleting"
title={`Confirm to delete ${selectedFunction?.name}`}
onCancel={() => setShowDeleteModal(false)}
onConfirm={onConfirmDelete}
alert={{
base: { variant: 'destructive' },
title: 'This action cannot be undone',
description:
'Ensure that you have made a backup if you want to restore your edge function',
}}
/>
</div>
<div className="w-full 2xl:max-w-[600px] shrink-0">
<ScaffoldSection isFullWidth className="!pt-6 2xl:first:!pt-12">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError error={error} subject="Failed to retrieve edge function details" />
)}
{isSuccess && (
<dl className="grid grid-cols-1 xl:grid-cols-[auto_1fr] gap-y-4 xl:gap-y-6 gap-x-10">
<dt className="text-sm text-foreground-light">Slug</dt>
<dd className="text-sm lg:text-left">{selectedFunction?.slug}</dd>
<dt className="text-sm text-foreground-light">Endpoint URL</dt>
<dd className="text-sm lg:text-left">
<Input
className="font-mono input-mono"
disabled
copy
size="small"
value={functionUrl}
/>
</dd>
<dt className="text-sm text-foreground-light">Region</dt>
<dd className="text-sm lg:text-left">All functions are deployed globally</dd>
<dt className="text-sm text-foreground-light">Created at</dt>
<dd className="text-sm lg:text-left">
{dayjs(selectedFunction?.created_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
</dd>
<dt className="text-sm text-foreground-light">Last updated at</dt>
<dd className="text-sm lg:text-left">
{dayjs(selectedFunction?.updated_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
</dd>
<dt className="text-sm text-foreground-light">Deployments</dt>
<dd className="text-sm lg:text-left">{selectedFunction?.version ?? 0}</dd>
<dt className="text-sm text-foreground-light">Import Maps</dt>
<dd className="text-sm lg:text-left">
<p>
Import maps are{' '}
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
{hasImportMap ? 'used' : 'not used'}
</span>{' '}
for this function
</p>
<p className="text-foreground-light mt-1">
Import maps allow the use of bare specifiers in functions instead of explicit
import URLs
</p>
<div className="mt-4">
<Button
asChild
type="default"
size="tiny"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="https://supabase.com/docs/guides/functions/dependencies"
target="_blank"
rel="noreferrer"
>
More about import maps
</Link>
</Button>
</div>
</dd>
</dl>
)}
</CardContent>
</Card>
</ScaffoldSection>
</div>
</div>
)
}