CreateCronJobSheet.tsx475 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { useWatch } from '@ui/components/shadcn/ui/form'
5import { useParams } from 'common'
6import { parseAsString, useQueryState } from 'nuqs'
7import { useEffect, useState } from 'react'
8import { SubmitHandler, useForm } from 'react-hook-form'
9import { toast } from 'sonner'
10import {
11 Button,
12 Form,
13 FormControl,
14 FormField,
15 Input,
16 RadioGroupStacked,
17 RadioGroupStackedItem,
18 Separator,
19 Sheet,
20 SheetContent,
21 SheetFooter,
22 SheetHeader,
23 SheetSection,
24 SheetTitle,
25 WarningIcon,
26} from 'ui'
27import { Admonition } from 'ui-patterns/admonition'
28import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
29
30import { CRONJOB_DEFINITIONS } from '../CronJobs.constants'
31import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from '../CronJobs.utils'
32import { EdgeFunctionSection } from '../EdgeFunctionSection'
33import { HttpBodyFieldSection } from '../HttpBodyFieldSection'
34import { HTTPHeaderFieldsSection } from '../HttpHeaderFieldsSection'
35import { HttpRequestSection } from '../HttpRequestSection'
36import { SqlFunctionSection } from '../SqlFunctionSection'
37import { SqlSnippetSection } from '../SqlSnippetSection'
38import {
39 FormSchema,
40 type CreateCronJobForm,
41 type CronJobType,
42} from './CreateCronJobSheet.constants'
43import { CronJobScheduleSection } from './CronJobScheduleSection'
44import { EnableExtensionModal } from '@/components/interfaces/Database/Extensions/EnableExtensionModal'
45import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
46import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
47import { getDatabaseCronJob } from '@/data/database-cron-jobs/database-cron-job-query'
48import { useDatabaseCronJobCreateMutation } from '@/data/database-cron-jobs/database-cron-jobs-create-mutation'
49import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
50import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
51import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
52import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
53import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
54import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
55import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
56import { isGreaterThanOrEqual } from '@/lib/semver'
57
58interface CreateCronJobSheetProps {
59 open: boolean
60 selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
61 onClose: () => void
62}
63
64const FORM_ID = 'create-cron-job-sidepanel'
65
66const buildCommand = (values: CronJobType) => {
67 let command = ''
68 if (values.type === 'edge_function') {
69 command = buildHttpRequestCommand(
70 values.method,
71 values.edgeFunctionName,
72 values.httpHeaders,
73 values.httpBody,
74 values.timeoutMs
75 )
76 } else if (values.type === 'http_request') {
77 command = buildHttpRequestCommand(
78 values.method,
79 values.endpoint,
80 values.httpHeaders,
81 values.httpBody,
82 values.timeoutMs
83 )
84 } else if (values.type === 'sql_function') {
85 command = `SELECT ${values.schema}.${values.functionName}()`
86 }
87 return command
88}
89
90export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCronJobSheetProps) => {
91 const { childId } = useParams()
92 const { data: project } = useSelectedProjectQuery()
93 const { data: org } = useSelectedOrganizationQuery()
94 const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
95 const [isLoadingGetCronJob, setIsLoadingGetCronJob] = useState(false)
96
97 const jobId = Number(childId)
98 const isEditing = !!selectedCronJob?.jobname
99 const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
100
101 const { data = [] } = useDatabaseExtensionsQuery({
102 projectRef: project?.ref,
103 connectionString: project?.connectionString,
104 })
105 const pgNetExtension = data.find((ext) => ext.name === 'pg_net')
106 const pgNetExtensionInstalled = pgNetExtension?.installed_version != undefined
107
108 const pgCronExtension = data.find((ext) => ext.name === 'pg_cron')
109 const supportsSeconds = pgCronExtension?.installed_version
110 ? isGreaterThanOrEqual(pgCronExtension.installed_version, '1.5')
111 : false
112
113 const { mutate: sendEvent } = useSendEventMutation()
114 const { mutate: upsertCronJob, isPending: isUpserting } = useDatabaseCronJobCreateMutation()
115 const isLoading = isLoadingGetCronJob || isUpserting
116
117 const { can: canToggleExtensions } = useAsyncCheckPermissions(
118 PermissionAction.TENANT_SQL_ADMIN_WRITE,
119 'extensions'
120 )
121
122 const cronJobValues = parseCronJobCommand(selectedCronJob?.command || '', project?.ref!)
123
124 const defaultValues = {
125 name: selectedCronJob?.jobname || '',
126 schedule: selectedCronJob?.schedule || '*/5 * * * *',
127 supportsSeconds,
128 values: cronJobValues,
129 }
130
131 const form = useForm<CreateCronJobForm>({
132 resolver: zodResolver(FormSchema as any),
133 defaultValues,
134 })
135
136 const [
137 cronType,
138 endpoint,
139 edgeFunctionName,
140 method,
141 httpHeaders,
142 httpBody,
143 timeoutMs,
144 schema,
145 functionName,
146 ] = useWatch({
147 control: form.control,
148 name: [
149 'values.type',
150 'values.endpoint',
151 'values.edgeFunctionName',
152 'values.method',
153 'values.httpHeaders',
154 'values.httpBody',
155 'values.timeoutMs',
156 'values.schema',
157 'values.functionName',
158 ],
159 })
160
161 const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
162 checkIsDirty: () => form.formState.isDirty,
163 onClose: () => onClose(),
164 })
165
166 const onSubmit: SubmitHandler<CreateCronJobForm> = async ({ name, schedule, values }) => {
167 if (!project) return console.error('Project is required')
168
169 if (!isEditing) {
170 try {
171 setIsLoadingGetCronJob(true)
172 const checkExistingJob = await getDatabaseCronJob({
173 projectRef: project.ref,
174 connectionString: project.connectionString,
175 name,
176 })
177 const nameExists = !!checkExistingJob
178
179 if (nameExists) {
180 return form.setError(
181 'name',
182 {
183 type: 'manual',
184 message: 'A cron job with this name already exists',
185 },
186 { shouldFocus: true }
187 )
188 }
189 } catch (error: any) {
190 toast.error(`Failed to validate cron job name: ${error.message}`)
191 return
192 } finally {
193 setIsLoadingGetCronJob(false)
194 }
195 }
196
197 const query = buildCronQuery(name, schedule, values.snippet)
198
199 upsertCronJob(
200 {
201 projectRef: project!.ref,
202 connectionString: project?.connectionString,
203 query,
204 searchTerm: searchQuery,
205 // [Joshen] Only need to invalidate a specific cron job if in the job's previous run tab
206 identifier: !!jobId ? jobId : undefined,
207 },
208 {
209 onSuccess: () => {
210 if (isEditing) {
211 toast.success(`Successfully updated cron job ${name}`)
212 } else {
213 toast.success(`Successfully created cron job ${name}`)
214 }
215
216 if (isEditing) {
217 sendEvent({
218 action: 'cron_job_updated',
219 properties: {
220 type: values.type,
221 schedule: schedule,
222 },
223 groups: {
224 project: project?.ref ?? 'Unknown',
225 organization: org?.slug ?? 'Unknown',
226 },
227 })
228 } else {
229 sendEvent({
230 action: 'cron_job_created',
231 properties: {
232 type: values.type,
233 schedule: schedule,
234 },
235 groups: {
236 project: project?.ref ?? 'Unknown',
237 organization: org?.slug ?? 'Unknown',
238 },
239 })
240 }
241
242 onClose()
243 },
244 }
245 )
246 setIsLoadingGetCronJob(false)
247 }
248
249 // update the snippet field when the user changes the any values in the form
250 useEffect(() => {
251 const command = buildCommand({
252 type: cronType,
253 method,
254 edgeFunctionName,
255 timeoutMs,
256 httpHeaders,
257 httpBody,
258 functionName,
259 schema,
260 endpoint,
261 snippet: '',
262 })
263 if (command) {
264 form.setValue('values.snippet', command)
265 }
266 // eslint-disable-next-line react-hooks/exhaustive-deps
267 }, [
268 cronType,
269 edgeFunctionName,
270 endpoint,
271 method,
272 // for some reason, the httpHeaders are not memoized and cause the useEffect to trigger even when the value is the same
273 JSON.stringify(httpHeaders),
274 httpBody,
275 timeoutMs,
276 schema,
277 functionName,
278 form,
279 ])
280
281 useEffect(() => {
282 if (open && !!pgCronExtension) form.reset(defaultValues)
283 // eslint-disable-next-line react-hooks/exhaustive-deps
284 }, [open])
285
286 return (
287 <>
288 <DiscardChangesConfirmationDialog {...modalProps} />
289
290 <Sheet open={open} onOpenChange={handleOpenChange}>
291 <SheetContent size="lg">
292 <div className="flex flex-col h-full" tabIndex={-1}>
293 <SheetHeader>
294 <SheetTitle>
295 {isEditing ? `Edit ${selectedCronJob.jobname}` : `Create a new cron job`}
296 </SheetTitle>
297 </SheetHeader>
298
299 <div className="overflow-auto grow">
300 <Form {...form}>
301 <form
302 id={FORM_ID}
303 className="grow overflow-auto"
304 onSubmit={form.handleSubmit(onSubmit)}
305 >
306 <SheetSection>
307 <FormField
308 control={form.control}
309 name="name"
310 render={({ field }) => (
311 <FormItemLayout label="Name" layout="vertical" className="gap-1 relative">
312 <FormControl>
313 <Input {...field} disabled={isEditing} />
314 </FormControl>
315 <span className="text-foreground-lighter text-xs absolute top-0 right-0">
316 Cron jobs cannot be renamed once created
317 </span>
318 </FormItemLayout>
319 )}
320 />
321 </SheetSection>
322 <Separator />
323 <CronJobScheduleSection form={form} supportsSeconds={supportsSeconds} />
324 <Separator />
325 <SheetSection>
326 <FormField
327 control={form.control}
328 name="values.type"
329 render={({ field }) => (
330 <FormItemLayout label="Type" layout="vertical" className="gap-1">
331 <FormControl>
332 <RadioGroupStacked
333 id="function_type"
334 name="function_type"
335 value={field.value}
336 disabled={field.disabled}
337 onValueChange={(value) => field.onChange(value)}
338 >
339 {CRONJOB_DEFINITIONS.map((definition) => (
340 <RadioGroupStackedItem
341 key={definition.value}
342 id={definition.value}
343 value={definition.value}
344 disabled={
345 !pgNetExtensionInstalled &&
346 (definition.value === 'http_request' ||
347 definition.value === 'edge_function')
348 }
349 label=""
350 showIndicator={false}
351 >
352 <div className="flex items-center gap-x-5">
353 <div className="text-foreground">{definition.icon}</div>
354 <div className="flex flex-col">
355 <div className="flex gap-x-2">
356 <p className="text-foreground">{definition.label}</p>
357 </div>
358 <p className="text-foreground-light">
359 {definition.description}
360 </p>
361 </div>
362 </div>
363 {!pgNetExtensionInstalled &&
364 (definition.value === 'http_request' ||
365 definition.value === 'edge_function') ? (
366 <div className="w-full flex gap-x-2 pl-11 py-2 items-center">
367 <WarningIcon />
368 <span className="text-xs">
369 <code>pg_net</code> needs to be installed to use this type
370 </span>
371 </div>
372 ) : null}
373 </RadioGroupStackedItem>
374 ))}
375 </RadioGroupStacked>
376 </FormControl>
377 </FormItemLayout>
378 )}
379 />
380 {!pgNetExtensionInstalled && (
381 <Admonition
382 type="note"
383 // @ts-ignore
384 title={
385 <span>
386 Enable <code className="text-code-inline w-min">pg_net</code> for HTTP
387 requests or Edge Functions
388 </span>
389 }
390 description={
391 <div className="flex flex-col gap-y-2">
392 <span>
393 This will allow you to send HTTP requests or trigger an edge function
394 within your cron jobs
395 </span>
396 <ButtonTooltip
397 type="default"
398 className="w-min"
399 disabled={!canToggleExtensions}
400 onClick={() => setShowEnableExtensionModal(true)}
401 tooltip={{
402 content: {
403 side: 'bottom',
404 text: !canToggleExtensions
405 ? 'You need additional permissions to enable database extensions'
406 : undefined,
407 },
408 }}
409 >
410 Install pg_net extension
411 </ButtonTooltip>
412 </div>
413 }
414 />
415 )}
416 </SheetSection>
417 <Separator />
418 {cronType === 'http_request' && (
419 <>
420 <HttpRequestSection form={form} />
421 <Separator />
422 <HTTPHeaderFieldsSection variant={cronType} />
423 <Separator />
424 <HttpBodyFieldSection form={form} />
425 </>
426 )}
427 {cronType === 'edge_function' && (
428 <>
429 <EdgeFunctionSection form={form} />
430 <Separator />
431 <HTTPHeaderFieldsSection variant={cronType} />
432 <Separator />
433 <HttpBodyFieldSection form={form} />
434 </>
435 )}
436 {cronType === 'sql_function' && <SqlFunctionSection form={form} />}
437 {cronType === 'sql_snippet' && <SqlSnippetSection form={form} />}
438 </form>
439 </Form>
440 </div>
441 <SheetFooter>
442 <Button
443 size="tiny"
444 type="default"
445 htmlType="button"
446 onClick={confirmOnClose}
447 disabled={isLoading}
448 >
449 Cancel
450 </Button>
451 <Button
452 size="tiny"
453 type="primary"
454 form={FORM_ID}
455 htmlType="submit"
456 disabled={isLoading}
457 loading={isLoading}
458 >
459 {isEditing ? `Save cron job` : 'Create cron job'}
460 </Button>
461 </SheetFooter>
462 </div>
463 </SheetContent>
464 </Sheet>
465
466 {pgNetExtension && (
467 <EnableExtensionModal
468 visible={showEnableExtensionModal}
469 extension={pgNetExtension}
470 onCancel={() => setShowEnableExtensionModal(false)}
471 />
472 )}
473 </>
474 )
475}