CronJobScheduleSection.tsx239 lines · main
1import { motion } from 'framer-motion'
2import { useEffect, useState } from 'react'
3import { UseFormReturn } from 'react-hook-form'
4import {
5 Accordion,
6 AccordionContent,
7 AccordionItem,
8 AccordionTrigger,
9 Button,
10 cn,
11 FormControl,
12 FormField,
13 FormItem,
14 FormLabel,
15 FormMessage,
16 Input,
17 SheetSection,
18 Switch,
19} from 'ui'
20import { useDebounce } from 'use-debounce'
21
22import { formatScheduleString, getScheduleMessage } from '../CronJobs.utils'
23import CronSyntaxChart from '../CronSyntaxChart'
24import { type CreateCronJobForm } from './CreateCronJobSheet.constants'
25import { useSqlCronGenerateMutation } from '@/data/ai/sql-cron-mutation'
26import { useCronTimezoneQuery } from '@/data/database-cron-jobs/database-cron-timezone-query'
27import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
28
29interface CronJobScheduleSectionProps {
30 form: UseFormReturn<CreateCronJobForm>
31 supportsSeconds: boolean
32}
33
34export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => {
35 const { data: project } = useSelectedProjectQuery()
36
37 const [inputValue, setInputValue] = useState('')
38 const [debouncedValue] = useDebounce(inputValue, 750)
39 const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
40
41 const PRESETS = [
42 ...(supportsSeconds ? [{ name: 'Every 30 seconds', expression: '30 seconds' }] : []),
43 { name: 'Every minute', expression: '* * * * *' },
44 { name: 'Every 5 minutes', expression: '*/5 * * * *' },
45 { name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
46 { name: 'Every night at midnight', expression: '0 0 * * *' },
47 { name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
48 ] as const
49
50 const { mutate: generateCronSyntax, isPending: isGeneratingCron } = useSqlCronGenerateMutation({
51 onSuccess: (expression) => {
52 form.setValue('schedule', expression, {
53 shouldValidate: true,
54 shouldDirty: true,
55 shouldTouch: true,
56 })
57 },
58 })
59
60 const { data: timezone } = useCronTimezoneQuery({
61 projectRef: project?.ref,
62 connectionString: project?.connectionString,
63 })
64
65 useEffect(() => {
66 if (useNaturalLanguage && debouncedValue) {
67 generateCronSyntax({ prompt: debouncedValue })
68 }
69 // eslint-disable-next-line react-hooks/exhaustive-deps
70 }, [debouncedValue, useNaturalLanguage])
71
72 const schedule = form.watch('schedule')
73 const scheduleString = formatScheduleString(schedule)
74
75 return (
76 <SheetSection>
77 <FormField
78 control={form.control}
79 name="schedule"
80 render={({ field }) => {
81 return (
82 <FormItem className="flex flex-col gap-1">
83 <div className="flex flex-row justify-between">
84 <FormLabel>Schedule</FormLabel>
85 <span className="text-foreground-lighter text-xs">
86 {useNaturalLanguage
87 ? 'Describe your schedule in words'
88 : 'Enter a cron expression'}
89 </span>
90 </div>
91
92 <FormControl>
93 {useNaturalLanguage ? (
94 <Input
95 value={inputValue}
96 placeholder="E.g. every 5 minutes"
97 onKeyDown={(e) => {
98 if (e.key === 'Enter') {
99 e.preventDefault()
100 }
101 }}
102 onChange={(e) => setInputValue(e.target.value)}
103 />
104 ) : (
105 <Input
106 {...field}
107 autoComplete="off"
108 placeholder="* * * * *"
109 onKeyDown={(e) => {
110 if (e.key === 'Enter') {
111 e.preventDefault()
112 }
113 }}
114 />
115 )}
116 </FormControl>
117 <FormMessage />
118 <div className="flex flex-col gap-y-4 mt-3 mb-2">
119 <div className="flex items-center gap-2">
120 <Switch
121 checked={useNaturalLanguage}
122 onCheckedChange={() => {
123 setUseNaturalLanguage(!useNaturalLanguage)
124 setInputValue('')
125 }}
126 />
127 <p className="text-sm text-foreground-light">Use natural language</p>
128 </div>
129
130 <ul className="flex gap-2 flex-wrap mt-2">
131 {PRESETS.map((preset) => (
132 <li key={preset.name}>
133 <Button
134 type="outline"
135 onClick={() => {
136 if (useNaturalLanguage) {
137 setUseNaturalLanguage(false)
138 }
139 form.setValue('schedule', preset.expression, {
140 shouldValidate: true,
141 shouldDirty: true,
142 shouldTouch: true,
143 })
144 }}
145 >
146 {preset.name}
147 </Button>
148 </li>
149 ))}
150 </ul>
151 <Accordion type="single" collapsible>
152 <AccordionItem value="item-1" className="border-none">
153 <AccordionTrigger className="text-xs text-foreground-light font-normal gap-2 justify-start py-1 ">
154 View syntax chart
155 </AccordionTrigger>
156 <AccordionContent asChild className="pb-0!">
157 <CronSyntaxChart />
158 </AccordionContent>
159 </AccordionItem>
160 </Accordion>
161 </div>
162 <div className="bg-surface-100 p-4 rounded-sm grid gap-y-4 border">
163 <h4 className="text-sm text-foreground">
164 Schedule {timezone ? `(${timezone})` : ''}
165 </h4>
166 <span
167 className={cn(
168 'text-xl font-mono',
169 scheduleString
170 ? isGeneratingCron
171 ? 'animate-pulse text-foreground-lighter'
172 : 'text-foreground'
173 : 'text-foreground-lighter'
174 )}
175 >
176 {isGeneratingCron ? <CronSyntaxLoader /> : schedule || '* * * * * *'}
177 </span>
178
179 {!inputValue && !isGeneratingCron && !scheduleString ? (
180 <span className="text-sm text-foreground-light">
181 Describe your schedule above
182 </span>
183 ) : (
184 <span className="text-sm text-foreground-light flex items-center gap-2">
185 {isGeneratingCron ? <LoadingDots /> : getScheduleMessage(scheduleString)}
186 </span>
187 )}
188 </div>
189 </FormItem>
190 )
191 }}
192 />
193 </SheetSection>
194 )
195}
196
197const CronSyntaxLoader = () => {
198 return (
199 <div className="flex gap-2">
200 {['*', '*', '*', '*', '*'].map((char, i) => (
201 <motion.span
202 key={i}
203 initial={{ opacity: 0.3 }}
204 animate={{ opacity: 1 }}
205 transition={{
206 duration: 0.6,
207 repeat: Infinity,
208 repeatType: 'reverse',
209 delay: i * 0.15,
210 }}
211 >
212 {char}
213 </motion.span>
214 ))}
215 </div>
216 )
217}
218
219const LoadingDots = () => {
220 return (
221 <span className="inline-flex items-center">
222 {[0, 1, 2].map((i) => (
223 <motion.span
224 key={i}
225 initial={{ opacity: 0 }}
226 animate={{ opacity: 1 }}
227 transition={{
228 duration: 0.5,
229 repeat: Infinity,
230 repeatType: 'reverse',
231 delay: i * 0.2,
232 }}
233 >
234 .
235 </motion.span>
236 ))}
237 </span>
238 )
239}