Files
supabase/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx
Joshen Lim fcfb0f0222 Refactor all usage of form.watch to either useWatch or subscribe (#48436)
## Context

Replaces all usage of `form.watch()` to use `useWatch` instead + follows
the "name what you watch" convention as specified in the react-hook-form
skills.

There's also a small refactor in `SmtpForm.tsx` which removes the
unnecessary use of a `useState` to track if SMTP is enabled or not

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Updated many Studio forms to watch specific fields more precisely,
improving live UI updates for previews, warnings, conditional sections,
and validation messages.
* Enhanced responsiveness across settings, authentication, billing,
storage, integrations, and support flows while keeping save/update
behavior the same.
* **Refined Experiences**
* Improved the analytics table creation flow with tighter, enum-based
column type validation and structured, type-specific column options.
* **Preserved Behavior**
* Maintained existing permission checks, submission flows, and
account-management workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 11:45:40 +08:00

512 lines
19 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { BookOpen, Loader2, Plus, Send, X } from 'lucide-react'
import { useState } from 'react'
import { useFieldArray, useForm, useWatch } from 'react-hook-form'
import {
Badge,
Button,
Form,
FormControl,
FormField,
Input,
Label,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { HTTP_METHODS } from './EdgeFunctionDetails.constants'
import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types'
import { getEdgeFunctionErrorDocs } from './EdgeFunctionDetails.utils'
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query'
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { useEdgeFunctionTestMutation } from '@/data/edge-functions/edge-function-test-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { IS_PLATFORM } from '@/lib/constants'
import { prettifyJSON } from '@/lib/helpers'
import { getRoleImpersonationJWT } from '@/lib/role-impersonation'
import { useTrack } from '@/lib/telemetry/track'
import {
RoleImpersonationStateContextProvider,
useGetImpersonatedRoleState,
} from '@/state/role-impersonation-state'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface EdgeFunctionTesterSheetProps {
visible: boolean
onClose: () => void
}
const FormSchema = z.object({
method: z.enum(HTTP_METHODS),
body: z
.string()
.optional()
.transform((str) => str || '{}'),
headers: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
queryParams: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
type FormValues = z.infer<typeof FormSchema>
export const EdgeFunctionTesterSheet = (props: EdgeFunctionTesterSheetProps) => {
const { ref: projectRef } = useParams()
// [Alaister]: We're using a fresh context here as edge functions don't allow impersonating users.
return (
<RoleImpersonationStateContextProvider key={`role-impersonation-state-${projectRef}`}>
<EdgeFunctionTesterSheetContent {...props} />
</RoleImpersonationStateContextProvider>
)
}
const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => {
const { ref: projectRef, functionSlug } = useParams()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const [response, setResponse] = useState<ResponseData | null>(null)
const [error, setError] = useState<string | null>(null)
const errorDocs = response ? getEdgeFunctionErrorDocs(response.headers) : undefined
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeysData } = useAPIKeys({ projectRef }, { enabled: canReadAPIKeys })
const { serviceKey } = apiKeysData ?? {}
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
const track = useTrack()
const { mutate: testEdgeFunction, isPending } = useEdgeFunctionTestMutation({
onSuccess: (res) => setResponse(res),
onError: (err) => {
setError(err instanceof Error ? err.message : 'An unknown error occurred')
if (err instanceof Error) {
const errorWithStatus = err as ErrorWithStatus
setResponse({
status: errorWithStatus.cause?.status || 500,
headers: {},
body: '',
})
}
},
})
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint ?? ''
const url = `${protocol}://${endpoint}/functions/v1/${functionSlug}`
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
method: 'POST',
body: '{ "name": "Functions" }',
headers: [{ key: '', value: '' }],
queryParams: [{ key: '', value: '' }],
},
})
const method = useWatch({ control: form.control, name: 'method' })
const {
fields: headerFields,
append: appendHeader,
remove: removeHeader,
} = useFieldArray({
control: form.control,
name: 'headers',
})
const {
fields: queryParamFields,
append: appendQueryParam,
remove: removeQueryParam,
} = useFieldArray({
control: form.control,
name: 'queryParams',
})
const addKeyValuePair = (type: 'headers' | 'queryParams') => {
if (type === 'headers') {
appendHeader({ key: '', value: '' })
} else {
appendQueryParam({ key: '', value: '' })
}
}
const removeKeyValuePair = (index: number, type: 'headers' | 'queryParams') => {
if (type === 'headers') {
removeHeader(index)
} else {
removeQueryParam(index)
}
}
useShortcut(
SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST,
() => {
form.handleSubmit(onSubmit)()
},
{ enabled: visible && !isPending }
)
const onSubmit = async (values: FormValues) => {
setError(null)
setResponse(null)
// Validate that the body is valid JSON
try {
JSON.parse(JSON.stringify(values.body))
} catch (e) {
form.setError('body', { message: 'Must be a valid JSON string' })
return
}
let testAuthorization: string | undefined
const role = getImpersonatedRoleState().role
if (
projectRef !== undefined &&
config?.jwt_secret !== undefined &&
role !== undefined &&
role.type === 'postgrest'
) {
try {
const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role)
testAuthorization = 'Bearer ' + token
} catch (err: any) {
console.error('Failed to generate JWT:', {
error: err.message,
roleDetails: role,
})
}
}
// Construct custom headers
const customHeaders: Record<string, string> = {}
values.headers.forEach(({ key, value }) => {
if (key && value) {
customHeaders[key] = value
}
})
// Construct query parameters
const queryString = values.queryParams
.filter(({ key, value }) => key && value)
.map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
const finalUrl = queryString ? `${url}?${queryString}` : url
testEdgeFunction({
url: finalUrl,
method: values.method,
body: values.body,
headers: {
...(accessToken && {
Authorization: `Bearer ${accessToken}`,
}),
'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`,
'Content-Type': 'application/json',
...customHeaders,
},
})
}
const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground text-sm">{label}</Label>
<Button
variant="default"
size="tiny"
icon={<Plus size={14} />}
onClick={() => addKeyValuePair(type)}
>
Add {label}
</Button>
</div>
<div className="border rounded-md bg-surface-200">
{(type === 'headers' ? headerFields : queryParamFields).map((field, index) => (
<div key={field.id} className="grid grid-cols-[1fr_1fr_32px] border-b last:border-b-0">
<FormField
control={form.control}
name={`${type}.${index}.key`}
render={({ field }) => (
<FormControl>
<Input
{...field}
size="tiny"
placeholder="Enter key..."
disabled={isPending}
className="h-auto py-2 font-mono rounded-none shadow-none bg-transparent border-l-0 border-r border-t-0 border-b-0 border-border"
/>
</FormControl>
)}
/>
<FormField
control={form.control}
name={`${type}.${index}.value`}
render={({ field }) => (
<FormControl>
<Input
{...field}
size="tiny"
placeholder="Enter value..."
disabled={isPending}
className="h-auto py-2 font-mono rounded-none shadow-none bg-transparent border-none"
/>
</FormControl>
)}
/>
<div className="flex items-center justify-center">
{(type === 'headers' ? headerFields : queryParamFields).length > 1 && (
<Button
variant="text"
size="tiny"
icon={<X strokeWidth={1.5} size={14} />}
className="w-6 h-6"
onClick={() => removeKeyValuePair(index, type)}
/>
)}
</div>
</div>
))}
</div>
</div>
)
return (
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent
size="default"
hasOverlay={false}
className="flex flex-col gap-0 p-0"
onPointerDownOutside={(e) => {
// react-resizable-panels v4 registers document-level capture-phase pointer
// handlers that can interfere with Radix Dialog's outside-interaction detection.
// Prevent the sheet from closing when interacting with the resize handle.
const target = (e as CustomEvent<{ originalEvent: PointerEvent }>).detail?.originalEvent
?.target as HTMLElement | null
if (target?.closest?.('[data-separator]')) {
e.preventDefault()
}
}}
onFocusOutside={(e) => {
// The v4 Separator explicitly calls .focus() on itself during pointerdown,
// which can trigger Radix Dialog's focus-outside detection.
const target = e.target as HTMLElement | null
if (target?.closest?.('[data-separator]')) {
e.preventDefault()
}
}}
>
<SheetHeader>
<SheetTitle>Test {functionSlug}</SheetTitle>
</SheetHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto flex flex-col"
>
<ResizablePanelGroup orientation="vertical">
<ResizablePanel>
<div className="flex flex-col gap-y-4 p-5 h-full overflow-y-auto">
<FormField
control={form.control}
name="method"
render={({ field }) => (
<FormItemLayout layout="vertical" label="HTTP Method">
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
{method !== 'GET' && (
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItemLayout layout="vertical" label="Request Body">
<FormControl>
<Textarea
{...field}
placeholder="Request body (JSON)"
rows={3}
disabled={isPending}
className="font-mono text-xs"
/>
</FormControl>
</FormItemLayout>
)}
/>
)}
{renderKeyValuePairs('headers', 'Headers')}
{renderKeyValuePairs('queryParams', 'Query Parameters')}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="41" minSize="41" maxSize="83">
<div className="h-full bg-surface-100 border-t flex-1 flex flex-col overflow-hidden">
{response ? (
<div className="h-full bg-surface-100 flex flex-col overflow-hidden">
{error ? (
<>
<div className="flex gap-2 items-center p-5 text-sm pb-3">
Function responded with
<Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
{response.status}
</Badge>
</div>
<p className="px-5 text-sm text-foreground-light">{error}</p>
</>
) : (
<Tabs
defaultValue="body"
className="h-full flex-1 flex flex-col overflow-hidden"
>
<TabsList className="gap-4 px-5 pt-2">
<div className="flex items-center gap-4 flex-1">
<TabsTrigger className="text-sm" value="body">
Body
</TabsTrigger>
<TabsTrigger className="text-sm" value="headers">
Headers
</TabsTrigger>
</div>
<div className="-translate-y-1 flex items-center gap-2">
{errorDocs !== undefined && (
<Button
asChild
variant="text"
size="tiny"
icon={<BookOpen size={14} />}
>
<a
href={errorDocs.href}
target="_blank"
rel="noreferrer"
aria-label={`View documentation for ${errorDocs.code} (opens in new tab)`}
>
Error docs
</a>
</Button>
)}
<Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
{response.status}
</Badge>
</div>
</TabsList>
<TabsContent value="body" className="mt-0 flex-1 overflow-auto p-0">
<CodeBlock
language="json"
hideLineNumbers
className="rounded-md border-none! px-4! py-3! h-full"
value={prettifyJSON(response.body)}
/>
</TabsContent>
<TabsContent value="headers" className="mt-0 flex-1 overflow-auto p-0">
<CodeBlock
language="json"
hideLineNumbers
className="rounded-md border-none! px-4! py-3! h-full"
value={prettifyJSON(JSON.stringify(response.headers, null, 2))}
/>
</TabsContent>
</Tabs>
)}
</div>
) : isPending ? (
<div className="h-full flex flex-col items-center justify-center gap-2">
<Loader2 size={24} className="text-foreground-muted animate-spin" />
<p className="text-sm text-foreground-light">Sending request...</p>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2">
<Send size={24} className="text-foreground-muted" />
<p className="text-sm text-foreground-light">Send your first test request</p>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
<SheetFooter className="px-5 py-3 border-t">
<div className="flex items-center gap-2">
<RoleImpersonationPopover
disallowAuthenticatedOption
header="Run edge function as a role"
/>
<ShortcutTooltip shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST} side="top">
<Button
variant="primary"
type="submit"
loading={isPending}
disabled={isPending}
onClick={() =>
track('edge_function_test_send_button_clicked', { httpMethod: method })
}
>
Send Request
</Button>
</ShortcutTooltip>
</div>
</SheetFooter>
</form>
</Form>
</SheetContent>
</Sheet>
)
}