code.tsx253 lines · main
| 1 | // @ts-nocheck |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { IS_PLATFORM, useParams } from 'common' |
| 4 | import { isEqual } from 'lodash' |
| 5 | import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react' |
| 6 | import { useEffect, useMemo, useState } from 'react' |
| 7 | import { toast } from 'sonner' |
| 8 | import { LogoLoader } from 'ui' |
| 9 | |
| 10 | import { DeployEdgeFunctionWarningModal } from '@/components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal' |
| 11 | import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils' |
| 12 | import { DefaultLayout } from '@/components/layouts/DefaultLayout' |
| 13 | import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' |
| 14 | import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' |
| 15 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 16 | import { FileExplorerAndEditor } from '@/components/ui/FileExplorerAndEditor' |
| 17 | import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' |
| 18 | import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query' |
| 19 | import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' |
| 20 | import { useEdgeFunctionDeployMutation } from '@/data/edge-functions/edge-functions-deploy-mutation' |
| 21 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 22 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 23 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 24 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 25 | import { BASE_PATH } from '@/lib/constants' |
| 26 | |
| 27 | const 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 | |
| 245 | CodePage.getLayout = (page: React.ReactNode) => { |
| 246 | return ( |
| 247 | <DefaultLayout> |
| 248 | <EdgeFunctionDetailsLayout title="Code">{page}</EdgeFunctionDetailsLayout> |
| 249 | </DefaultLayout> |
| 250 | ) |
| 251 | } |
| 252 | |
| 253 | export default CodePage |