Files
Danny White 476d4a5851 refactor(ui): drop redundant Button variant="default" props (#50161)
## What kind of change does this PR introduce?

Mechanical cleanup on top of the Button default-variant change (#50160).

## What is the current behavior?

Many callsites still pass `variant="default"` even though that is now
the component default.

## What is the new behavior?

Removes redundant static `variant="default"` from legacy `Button` and
`ButtonTooltip` callsites. Keeps explicit defaults where they document
the API:

- `button-default.tsx` and `button-sizes.tsx` demos
- `DocsButton`, which pins neutral styling at the wrapper boundary

## To test

Studio:

- [Auth → Rate
Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits):
dirty the form so Cancel appears; Cancel stays neutral, Save stays green
- [Project Settings → API
Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys):
`DocsButton` in the header actions stays neutral

Design system:

- [Design system →
Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button):
`button-default` / `button-sizes` still show explicit default styling;
Primary (green) is restricted to the Primary section (and `asChild`)

WWW:

- [www → Brand
assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets):
Download logo kit / Download button kit stay neutral
2026-09-11 17:05:26 +10:00

365 lines
12 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,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { type CronTableColumn } from './CronJobs.constants'
import { useDatabaseCronJobRunCommandMutation } from '@/data/database-cron-jobs/database-cron-job-run-mutation'
import { type 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: CronTableColumn
row: CronJob
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 as keyof typeof row]
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 as string).valueOf()
: undefined
: col.id === 'next_run'
? getNextRun(schedule, latest_run)
: value
) as string
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>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="text"
loading={isRunning}
className="h-6 w-6"
icon={<MoreVertical />}
onClick={(e) => e.stopPropagation()}
aria-label={`${jobname} actions`}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{jobname} actions</TooltipContent>
</Tooltip>
<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 (
<>
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
<Switch
id={`cron-job-active-${jobid}`}
size="medium"
disabled={isToggling}
checked={active}
aria-label="Cron job active status"
onClick={(e) => {
e.stopPropagation()
setShowToggleModal(true)
}}
/>
</div>
<Dialog open={showToggleModal} onOpenChange={setShowToggleModal}>
<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 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>
)
}