code.tsx253 lines · main
1// @ts-nocheck
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { IS_PLATFORM, useParams } from 'common'
4import { isEqual } from 'lodash'
5import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react'
6import { useEffect, useMemo, useState } from 'react'
7import { toast } from 'sonner'
8import { LogoLoader } from 'ui'
9
10import { DeployEdgeFunctionWarningModal } from '@/components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal'
11import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils'
12import { DefaultLayout } from '@/components/layouts/DefaultLayout'
13import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
14import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
15import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
16import { FileExplorerAndEditor } from '@/components/ui/FileExplorerAndEditor'
17import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types'
18import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query'
19import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
20import { useEdgeFunctionDeployMutation } from '@/data/edge-functions/edge-functions-deploy-mutation'
21import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
22import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
23import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
24import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
25import { BASE_PATH } from '@/lib/constants'
26
27const CodePage = () => {
28 const { ref, functionSlug } = useParams()
29 const { data: project } = useSelectedProjectQuery()
30 const { data: org } = useSelectedOrganizationQuery()
31
32 const { mutate: sendEvent } = useSendEventMutation()
33 const [showDeployWarning, setShowDeployWarning] = useState(false)
34
35 const { can: canDeployFunction } = useAsyncCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
36
37 const { data: selectedFunction } = useEdgeFunctionQuery({
38 projectRef: ref,
39 slug: functionSlug,
40 })
41 const {
42 data: functionBody,
43 isPending: isLoadingFiles,
44 isError: isErrorLoadingFiles,
45 isSuccess: isSuccessLoadingFiles,
46 error: filesError,
47 } = useEdgeFunctionBodyQuery(
48 {
49 projectRef: ref,
50 slug: functionSlug,
51 },
52 {
53 // [Alaister]: These parameters prevent the function files
54 // from being refetched when the user is editing the code
55 retry: false,
56 retryOnMount: false,
57 refetchOnWindowFocus: false,
58 staleTime: Infinity,
59 refetchOnMount: false,
60 refetchOnReconnect: false,
61 refetchInterval: false,
62 refetchIntervalInBackground: false,
63 }
64 )
65 const [files, setFiles] = useState<FileData[]>([])
66
67 const initialFiles = useMemo(() => {
68 return !!functionBody
69 ? formatFunctionBodyToFiles({
70 functionBody,
71 entrypointPath: selectedFunction?.entrypoint_path,
72 })
73 : []
74 }, [functionBody, selectedFunction?.entrypoint_path])
75
76 const { mutate: deployFunction, isPending: isDeploying } = useEdgeFunctionDeployMutation({
77 onSuccess: () => {
78 toast.success('Successfully updated edge function')
79 setShowDeployWarning(false)
80 setFiles((files) =>
81 files.map((f) => {
82 return { ...f, state: 'unchanged' }
83 })
84 )
85 },
86 })
87
88 const fileExists = (filePath: string | undefined): boolean => {
89 return filePath ? files.some((file) => file.name === filePath) : false
90 }
91
92 const onUpdate = async () => {
93 if (isDeploying || !ref || !functionSlug || !selectedFunction || files.length === 0) return
94
95 try {
96 const entrypoint_path =
97 functionBody?.metadata?.deno2_entrypoint_path ?? selectedFunction.entrypoint_path
98 const newEntrypointPath = entrypoint_path?.split('/').pop()
99 const newImportMapPath = selectedFunction.import_map_path?.split('/').pop()
100
101 const entrypointExists = fileExists(newEntrypointPath)
102 const importMapExists = fileExists(newImportMapPath)
103
104 deployFunction({
105 projectRef: ref,
106 slug: selectedFunction.slug,
107 metadata: {
108 name: selectedFunction.name,
109 verify_jwt: selectedFunction.verify_jwt,
110 ...(entrypointExists && { entrypoint_path: newEntrypointPath }),
111 ...(importMapExists && { import_map_path: newImportMapPath }),
112 },
113 files: files.map(({ name, content }) => ({ name, content })),
114 })
115 } catch (error) {
116 toast.error(
117 `Failed to update function: ${error instanceof Error ? error.message : 'Unknown error'}`
118 )
119 }
120 }
121
122 const handleDeployClick = () => {
123 if (files.length === 0 || isLoadingFiles) return
124 setShowDeployWarning(true)
125 sendEvent({
126 action: 'edge_function_deploy_updates_button_clicked',
127 groups: {
128 project: ref ?? 'Unknown',
129 organization: org?.slug ?? 'Unknown',
130 },
131 })
132 }
133
134 const handleDeployConfirm = () => {
135 sendEvent({
136 action: 'edge_function_deploy_updates_confirm_clicked',
137 groups: {
138 project: ref ?? 'Unknown',
139 organization: org?.slug ?? 'Unknown',
140 },
141 })
142 onUpdate()
143 }
144
145 useEffect(() => {
146 if (initialFiles.length === 0) return
147 setFiles(initialFiles)
148 }, [initialFiles])
149
150 const hasUnsavedChanges = useMemo(() => {
151 const normalizeFiles = (list: FileData[]) =>
152 list.map(({ id, name, content }) => ({ id, name, content }))
153 return !isEqual(normalizeFiles(initialFiles), normalizeFiles(files))
154 }, [initialFiles, files])
155
156 return (
157 <div className="flex flex-col h-full">
158 {isLoadingFiles && (
159 <div className="flex flex-col items-center justify-center h-full bg-surface-200">
160 <LogoLoader />
161 </div>
162 )}
163
164 {isErrorLoadingFiles && (
165 <div className="flex flex-col items-center justify-center h-full bg-surface-200">
166 <div className="flex flex-col items-center text-center gap-2 max-w-md">
167 <AlertCircle size={24} strokeWidth={1.5} className="text-amber-900" />
168 <h3 className="text-md mt-4">Failed to load function code</h3>
169 <p className="text-sm text-foreground-light">
170 {filesError?.message ||
171 'There was an error loading the function code. The format may be invalid or the function may be corrupted.'}
172 </p>
173 </div>
174 </div>
175 )}
176
177 {isSuccessLoadingFiles && (
178 <>
179 <FileExplorerAndEditor
180 files={files}
181 onFilesChange={(files) => {
182 const formattedFiles: FileData[] = files.map((f) => {
183 const originalFile = initialFiles.find((x) => x.id === f.id)
184 if (!originalFile) {
185 return f
186 } else if (originalFile.name !== f.name) {
187 return { ...f, state: 'new' }
188 } else if (originalFile.content !== f.content) {
189 return { ...f, state: 'modified' }
190 }
191 return { ...f, state: 'unchanged' }
192 })
193 setFiles(formattedFiles)
194 }}
195 aiEndpoint={`${BASE_PATH}/api/ai/code/complete`}
196 aiMetadata={{
197 projectRef: project?.ref,
198 connectionString: project?.connectionString,
199 orgSlug: org?.slug,
200 }}
201 />
202 {IS_PLATFORM && (
203 <div className="flex items-center bg-background-muted justify-end p-4 border-t bg-surface-100 shrink-0">
204 <ButtonTooltip
205 loading={isDeploying}
206 size="medium"
207 disabled={!canDeployFunction || files.length === 0 || isLoadingFiles}
208 onClick={handleDeployClick}
209 iconRight={
210 isDeploying ? (
211 <Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
212 ) : (
213 <div className="flex items-center space-x-1">
214 <CornerDownLeft size={10} strokeWidth={1.5} />
215 </div>
216 )
217 }
218 tooltip={{
219 content: {
220 side: 'top',
221 text: !canDeployFunction
222 ? 'You need additional permissions to update edge functions'
223 : undefined,
224 },
225 }}
226 >
227 Deploy updates
228 </ButtonTooltip>
229 </div>
230 )}
231 </>
232 )}
233
234 <DeployEdgeFunctionWarningModal
235 visible={showDeployWarning}
236 onCancel={() => setShowDeployWarning(false)}
237 onConfirm={handleDeployConfirm}
238 isDeploying={isDeploying}
239 />
240 <PreventNavigationOnUnsavedChanges hasChanges={hasUnsavedChanges} />
241 </div>
242 )
243}
244
245CodePage.getLayout = (page: React.ReactNode) => {
246 return (
247 <DefaultLayout>
248 <EdgeFunctionDetailsLayout title="Code">{page}</EdgeFunctionDetailsLayout>
249 </DefaultLayout>
250 )
251}
252
253export default CodePage