Files
supabase/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx
Gildas Garcia 4e86c39ea1 chore: remove <ContextMenu> _Shadcn_ suffix (#45971)
## Problem

The `_Shadcn_` suffix isn't needed anymore on `<ContextMenu_Shadcn_>`
and related components

## Solution

Remove it. No other changes

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Replaced legacy context-menu component variants with the unified UI
context-menu components across the app for consistent rendering and
imports; behavior and menu content remain unchanged.
* **Tests**
* Updated a test mock to track the unified context-menu component mount
count.
* **Chores**
* Simplified UI package re-exports to expose the canonical context-menu
symbols.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45971)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 15:09:25 +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
type="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 type="default" onClick={() => setShowToggleModal(false)}>
Cancel
</Button>
<Button
type={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 doesnt have a name and cant 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>
)
}