Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface AddNewPaymentMethodModalProps {
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: boolean
}

const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
Expand All @@ -28,6 +29,7 @@ const AddNewPaymentMethodModal = ({
onCancel,
onConfirm,
showSetDefaultCheckbox,
autoMarkAsDefaultPaymentMethod,
}: AddNewPaymentMethodModalProps) => {
const { resolvedTheme } = useTheme()
const [intent, setIntent] = useState<any>()
Expand Down Expand Up @@ -140,6 +142,7 @@ const AddNewPaymentMethodModal = ({
onCancel={onLocalCancel}
onConfirm={onLocalConfirm}
showSetDefaultCheckbox={showSetDefaultCheckbox}
autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod}
/>
</Elements>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface AddPaymentMethodFormProps {
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: boolean
}

// Stripe docs recommend to use the new SetupIntent flow over
Expand All @@ -23,6 +24,7 @@ const AddPaymentMethodForm = ({
onCancel,
onConfirm,
showSetDefaultCheckbox = false,
autoMarkAsDefaultPaymentMethod = false,
}: AddPaymentMethodFormProps) => {
const stripe = useStripe()
const elements = useElements()
Expand Down Expand Up @@ -59,7 +61,11 @@ const AddPaymentMethodForm = ({
setIsSaving(false)
toast.error(error?.message ?? ' Failed to save card details')
} else {
if (isDefault && selectedOrganization && typeof setupIntent?.payment_method === 'string') {
if (
(isDefault || autoMarkAsDefaultPaymentMethod) &&
selectedOrganization &&
typeof setupIntent?.payment_method === 'string'
) {
try {
await markAsDefault({
slug: selectedOrganization.slug,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormLabel_Shadcn_,
Input_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Expand All @@ -33,13 +32,14 @@ import { Admonition } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'

import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
import {
buildCronQuery,
buildHttpRequestCommand,
cronPattern,
secondsPattern,
parseCronJobCommand,
secondsPattern,
} from './CronJobs.utils'
import { CronJobScheduleSection } from './CronJobScheduleSection'
import { EdgeFunctionSection } from './EdgeFunctionSection'
Expand All @@ -48,10 +48,10 @@ import { HTTPParameterFieldsSection } from './HttpParameterFieldsSection'
import { HttpRequestSection } from './HttpRequestSection'
import { SqlFunctionSection } from './SqlFunctionSection'
import { SqlSnippetSection } from './SqlSnippetSection'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'

export interface CreateCronJobSheetProps {
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
supportsSeconds: boolean
isClosing: boolean
setIsClosing: (v: boolean) => void
onClose: () => void
Expand Down Expand Up @@ -90,32 +90,45 @@ const sqlSnippetSchema = z.object({
snippet: z.string().trim().min(1),
})

const FormSchema = z.object({
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
schedule: z
.string()
.trim()
.min(1)
.refine((value) => {
if (cronPattern.test(value)) {
try {
CronToString(value)
const FormSchema = z
.object({
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
supportsSeconds: z.boolean(),
schedule: z
.string()
.trim()
.min(1)
.refine((value) => {
if (cronPattern.test(value)) {
try {
CronToString(value)
return true
} catch {
return false
}
} else if (secondsPattern.test(value)) {
return true
} catch {
return false
}
} else if (secondsPattern.test(value)) {
return true
return false
}, 'Invalid Cron format'),
values: z.discriminatedUnion('type', [
edgeFunctionSchema,
httpRequestSchema,
sqlFunctionSchema,
sqlSnippetSchema,
]),
})
.superRefine((data, ctx) => {
if (!cronPattern.test(data.schedule)) {
if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.',
path: ['schedule'],
})
}
return false
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
values: z.discriminatedUnion('type', [
edgeFunctionSchema,
httpRequestSchema,
sqlFunctionSchema,
sqlSnippetSchema,
]),
})
}
})

export type CreateCronJobForm = z.infer<typeof FormSchema>
export type CronJobType = CreateCronJobForm['values']
Expand All @@ -124,11 +137,14 @@ const FORM_ID = 'create-cron-job-sidepanel'

export const CreateCronJobSheet = ({
selectedCronJob,
supportsSeconds,
isClosing,
setIsClosing,
onClose,
}: CreateCronJobSheetProps) => {
const { project } = useProjectContext()
const isEditing = !!selectedCronJob?.jobname

const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { mutate: upsertCronJob, isLoading } = useDatabaseCronJobCreateMutation()

Expand All @@ -144,11 +160,11 @@ export const CreateCronJobSheet = ({
defaultValues: {
name: selectedCronJob?.jobname || '',
schedule: selectedCronJob?.schedule || '*/5 * * * *',
supportsSeconds,
values: cronJobValues,
},
})

const { project } = useProjectContext()
const isEdited = form.formState.isDirty

// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
Expand Down Expand Up @@ -244,16 +260,15 @@ export const CreateCronJobSheet = ({
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} disabled={isEditing} />
</FormControl_Shadcn_>

<FormLabel_Shadcn_ className="text-foreground-lighter text-xs absolute top-0 right-0 ">
<span className="text-foreground-lighter text-xs absolute top-0 right-0">
Cron jobs cannot be renamed once created
</FormLabel_Shadcn_>
</span>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
<CronJobScheduleSection form={form} />
<CronJobScheduleSection form={form} supportsSeconds={supportsSeconds} />
<Separator />
<SheetSection>
<FormField_Shadcn_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,39 +19,41 @@ import {
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
SheetSection,
Switch,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateCronJobForm } from './CreateCronJobSheet'
import { getScheduleMessage, secondsPattern } from './CronJobs.utils'
import CronSyntaxChart from './CronSyntaxChart'
import { secondsPattern } from './CronJobs.utils'

interface CronJobScheduleSectionProps {
form: UseFormReturn<CreateCronJobForm>
supportsSeconds: boolean
}

const PRESETS = [
{ name: 'Every minute', expression: '* * * * *' },
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
{ name: 'Every night at midnight', expression: '0 0 * * *' },
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
{ name: 'Every 30 seconds', expression: '30 seconds' },
] as const

export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => {
const { project } = useProjectContext()
const initialValue = form.getValues('schedule')
const { schedule } = form.watch()
const schedule = form.watch('schedule')

const [presetValue, setPresetValue] = useState<string>(initialValue)
const [inputValue, setInputValue] = useState(initialValue)
const [debouncedValue] = useDebounce(inputValue, 750)
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
const [scheduleString, setScheduleString] = useState('')

const PRESETS = [
...(supportsSeconds ? [{ name: 'Every 30 seconds', expression: '30 seconds' }] : []),
{ name: 'Every minute', expression: '* * * * *' },
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
{ name: 'Every night at midnight', expression: '0 0 * * *' },
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
] as const

const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({
api: `${BASE_PATH}/api/ai/sql/cron`,
onResponse: async (response) => {
Expand Down Expand Up @@ -102,10 +104,18 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
}

try {
// Don't allow seconds-based schedules if seconds aren't supported
if (!supportsSeconds && secondsPattern.test(schedule)) {
setScheduleString('Invalid cron expression')
return
}

setScheduleString(CronToString(schedule))
} catch (error) {
setScheduleString('Invalid cron expression')
console.error('Error converting cron expression to string:', error)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule])

return (
Expand All @@ -116,10 +126,15 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
render={({ field }) => {
return (
<FormItem_Shadcn_ className="flex flex-col gap-1">
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground-lighter">
{useNaturalLanguage ? 'Describe your schedule in words' : 'Enter a cron expression'}
</FormLabel_Shadcn_>
<div className="flex flex-row justify-between">
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
<span className="text-foreground-lighter text-xs">
{useNaturalLanguage
? 'Describe your schedule in words'
: 'Enter a cron expression'}
</span>
</div>

<FormControl_Shadcn_>
<div className="flex flex-col gap-y-2">
{useNaturalLanguage ? (
Expand All @@ -146,6 +161,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
}}
/>
)}
<FormMessage_Shadcn_ />

<div className="flex items-center gap-2 mt-2">
<Switch
Expand All @@ -170,6 +186,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
onClick={() => {
setUseNaturalLanguage(false)
form.setValue('schedule', preset.expression)
form.trigger('schedule')
setPresetValue(preset.expression)
}}
>
Expand Down Expand Up @@ -218,20 +235,8 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
<span className="text-sm text-foreground-light flex items-center gap-2">
{isGeneratingCron ? (
<LoadingDots />
) : scheduleString === '' ? ( // set a min length before showing invalid message
'Enter a valid cron expression above'
) : scheduleString.includes('Invalid cron expression') ? (
'Invalid cron expression'
) : (
<>
The cron will be run{' '}
{secondsPattern.test(schedule)
? 'every ' + schedule
: scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
.join(' ') + '.'}
</>
getScheduleMessage(scheduleString, schedule)
)}
</span>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const buildHttpRequestCommand = (
$$`
}

export const DEFAULT_CRONJOB_COMMAND = {
const DEFAULT_CRONJOB_COMMAND = {
type: 'sql_snippet',
snippet: '',
} as const
Expand Down Expand Up @@ -136,11 +136,33 @@ export function formatDate(dateString: string): string {
return date.toLocaleString(undefined, options)
}

// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
export const secondsPattern = /^\d+\s+seconds$/
export const cronPattern =
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/

// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
export const secondsPattern = /^\d+\s+seconds$/

export function isSecondsFormat(schedule: string): boolean {
return secondsPattern.test(schedule.trim())
}

export function getScheduleMessage(scheduleString: string, schedule: string) {
if (!scheduleString) {
return 'Enter a valid cron expression above'
}

if (secondsPattern.test(schedule)) {
return `The cron will be run every ${schedule}`
}

if (scheduleString.includes('Invalid cron expression')) {
return scheduleString
}

const readableSchedule = scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLowerCase() : s))
.join(' ')

return `The cron will be run ${readableSchedule}.`
}
Loading
Loading