Files
supabase/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx
Gildas GarciaandIvan Vasilov 96d43099bb chore: refactor Button API so that it can be used a standard button (#46880)
## Problem

Our `<Button>` component breaks the default `button` contract by
redefining the `type` prop to set its variant (`primary`, `default`,
etc) instead of the button type (`submit`, `button`, etc).
This is confusing and forces to write more code when using it with
shadcn components that expect/inject the standard button props.

## Solution

- rename the `type` prop to `variant`
- rename the `htmlType` prop to `type`
- propagate the changes where necessary
- format code

## How to test

As this is just prop renaming, if it builds it's ok

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-06-16 23:59:58 +02:00

353 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import parser from 'cron-parser'
import dayjs from 'dayjs'
import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
cn,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
copyToClipboard,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { useDatabaseCronJobRunCommandMutation } from '@/data/database-cron-jobs/database-cron-job-run-mutation'
import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
import { useDatabaseCronJobToggleMutation } from '@/data/database-cron-jobs/database-cron-jobs-toggle-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const getNextRun = (schedule: string, lastRun?: string) => {
// cron-parser can only deal with the traditional cron syntax but technically users can also
// use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
// (can't guarantee as scope is quite big)
if (schedule.includes('*') || schedule.includes('$')) {
try {
// pg_cron uses '$' for "last day of month", but cron-parser uses 'L'
// Convert pg_cron syntax to cron-parser syntax before parsing
const normalizedSchedule = schedule.replace(/\$/g, 'L')
const interval = parser.parseExpression(normalizedSchedule, { tz: 'UTC' })
return interval.next().getTime()
} catch (error) {
return undefined
}
} else {
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds"
// Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
const [value, unit] = schedule.toLocaleLowerCase().split(' ')
if (
['second', 'seconds'].includes(unit) &&
!Number.isNaN(Number(value)) &&
lastRun !== undefined
) {
const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType)
return parsedLastRun.valueOf()
} else {
return undefined
}
}
}
interface CronJobTableCellProps {
col: any
row: any
onSelectEdit: (job: CronJob) => void
onSelectDelete: (job: CronJob) => void
}
export const CronJobTableCell = ({
col,
row,
onSelectEdit,
onSelectDelete,
}: CronJobTableCellProps) => {
const { data: project } = useSelectedProjectQuery()
const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
const [showToggleModal, setShowToggleModal] = useState(false)
const value = row?.[col.id]
const { jobid, schedule, latest_run, status, active, jobname } = row
const formattedValue =
col.id === 'jobname' && !jobname
? 'No name provided'
: col.id === 'lastest_run'
? !!value
? dayjs(value).valueOf()
: undefined
: col.id === 'next_run'
? getNextRun(schedule, latest_run)
: value
const hasValue = col.id === 'next_run' ? !!formattedValue : col.id in row
const { mutate: runCronJob, isPending: isRunning } = useDatabaseCronJobRunCommandMutation({
onSuccess: () => {
toast.success(`Command from "${jobname}" ran successfully`)
},
})
const { mutate: toggleDatabaseCronJob, isPending: isToggling } = useDatabaseCronJobToggleMutation(
{
onSuccess: (_, vars) => {
toast.success(`Successfully ${vars.active ? 'enabled' : 'disabled'} "${jobname}"`)
setShowToggleModal(false)
},
}
)
const onRunCronJob = () => {
runCronJob({
projectRef: project?.ref!,
connectionString: project?.connectionString,
jobId: jobid,
})
}
const onConfirmToggle = () => {
toggleDatabaseCronJob({
projectRef: project?.ref!,
connectionString: project?.connectionString,
jobId: jobid,
active: !active,
searchTerm: searchQuery,
})
}
if (col.id === 'actions') {
return (
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="text"
loading={isRunning}
className="h-6 w-6"
icon={<MoreVertical />}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 space-y-1">
<Tooltip>
<TooltipTrigger className="w-full">
<DropdownMenuItem
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
onRunCronJob()
}}
>
<Play size={12} />
Run command
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent>
Manual runs execute the command immediately and will not appear in the cron jobs
table.
</TooltipContent>
</Tooltip>
<DropdownMenuItem
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
onSelectEdit(row)
}}
>
<Edit size={12} />
Edit job
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
onSelectDelete(row)
}}
>
<Trash size={12} />
Delete job
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
if (col.id === 'active') {
return (
<Dialog open={showToggleModal} onOpenChange={setShowToggleModal}>
<DialogTrigger className="flex items-center" onClick={(e) => e.stopPropagation()}>
<Switch
id={`cron-job-active-${jobid}`}
size="medium"
disabled={isToggling}
checked={active}
/>
</DialogTrigger>
<DialogContent
onClick={(e) => e.stopPropagation()}
dialogOverlayProps={{ onClick: (e) => e.stopPropagation() }}
>
<DialogHeader>
<DialogTitle>{active ? 'Disable' : 'Enable'} cron job</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection>
<p className="text-sm">
Are you sure you want to {active ? 'disable' : 'enable'} the cron job "{jobname}
"?{' '}
</p>
</DialogSection>
<DialogFooter>
<Button variant="default" onClick={() => setShowToggleModal(false)}>
Cancel
</Button>
<Button
variant={active ? 'warning' : 'primary'}
loading={isToggling}
onClick={onConfirmToggle}
>
{active ? 'Disable' : 'Enable'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className={cn('w-full flex items-center text-xs')}>
{['latest_run', 'next_run'].includes(col.id) ? (
!hasValue ? (
<Minus size={14} className="text-foreground-lighter" />
) : col.id === 'latest_run' && formattedValue === null ? (
<p className="text-foreground-lighter">Job has not been run yet</p>
) : col.id === 'next_run' && !formattedValue ? (
<p className="text-foreground-lighter">Unable to parse next run for job</p>
) : (
<TimestampInfo
utcTimestamp={formattedValue}
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
className="font-sans text-xs"
/>
)
) : col.id === 'command' ? (
<HoverCard openDelay={0} closeDelay={0}>
<HoverCardTrigger asChild>
<div className="text-xs font-mono w-full h-full flex items-center">
{formattedValue}
</div>
</HoverCardTrigger>
<HoverCardContent
align="end"
className="p-0 w-[400px]"
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs font-mono px-2 py-1 border-b">Command</p>
<CodeBlock
hideLineNumbers
language="sql"
value={formattedValue.trim()}
className={cn(
'py-0 px-3.5 max-w-full prose dark:prose-dark border-0 rounded-t-none',
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11',
'[&>code]:text-xs'
)}
/>
</HoverCardContent>
</HoverCard>
) : (
<p
className={cn(
col.id === 'jobname' && !jobname && 'text-foreground-lighter',
col.id === 'command' && 'font-mono'
)}
>
{formattedValue}
</p>
)}
{col.id === 'latest_run' && !!status && (
<Badge
variant={status === 'failed' ? 'destructive' : 'success'}
className="capitalize ml-2"
>
{status}
</Badge>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent onClick={(e) => e.stopPropagation()}>
<ContextMenuItem
className="gap-x-2"
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => copyToClipboard(formattedValue)}
>
<Copy size={12} />
<span>Copy {col.name.toLowerCase()}</span>
</ContextMenuItem>
<ContextMenuItem
disabled={!jobname}
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => onSelectEdit(row)}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 w-full">
<Edit size={12} />
<span>Edit job</span>
</div>
</TooltipTrigger>
{!jobname && (
<TooltipContent side="right" className="w-56">
This cron job doesn’t have a name and can’t be edited. Create a new one and delete
this job.
</TooltipContent>
)}
</Tooltip>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="gap-x-2"
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => onSelectDelete(row)}
>
<Trash size={12} />
<span>Delete job</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}