Files
Danny WhiteandJoshen Lim 8a5ad58f81 chore(studio): replace CloseConfirmationModal with DiscardChangesConfirmationDialog (#43430)
## What kind of change does this PR introduce?

Form handling improvement.

## What is the current behavior?

https://github.com/supabase/supabase/pull/43201/ standardised our
discard changes behaviour with a shared hook and
`DiscardChangesConfirmationDialog` component. But many forms and sheets
still:

1. Don’t have any Discard-confirm close behaviour, making it too easy to
make accidental discards
2. Use a more complicated, manually-created `CloseConfirmationModal`
approach

## What is the new behavior?

- Replaced all instances of `#2` above that had `CloseConfirmationModal`
with `DiscardChangesConfirmationDialog` and its hook
- Improved design system documentation around dirty form dismissal

| Before | After |
| --- | --- |
| <img width="987" height="569" alt="Mercor Apexroles Foo
Supabase-9A40EC7C-F335-4B26-B567-450FC0845463"
src="https://github.com/user-attachments/assets/363bed82-34d2-4cc8-9164-6d18cfdbdbbc"
/> | <img width="987" height="569" alt="Mercor Apexroles Foo
Supabase-F427F1FA-DECC-4194-B663-A9E5A6F285A1"
src="https://github.com/user-attachments/assets/d49fafdc-a5c2-46df-9b67-ec42bacbe716"
/> |

## To test

Try editing values these sheets in staging, then blurring the sheet or
pressing `esc`:

- CreateQueueSheet.tsx
- CronJobsTab.tsx
- CronJobPage.tsx
- EditWrapperSheet.tsx
- OverviewTab.tsx
- WrappersTab.tsx
- CreateFunction/index.tsx
- EditHookPanel.tsx
- TriggerSheet.tsx
- SidePanelEditor.tsx
- EditSecretSheet.tsx
- PolicyEditorModal/index.tsx
- PolicyEditorPanel/index.tsx

## Still to come

- [ ] Incrementally take on `#1`: implement
`DiscardChangesConfirmationDialog` and its hook in sheets or dialog
forms that have no dirty form dismissal handling

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-03-06 03:17:35 +00:00

214 lines
7.1 KiB
TypeScript

import { useParams } from 'common'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet'
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers'
import { createNavigationHandler } from 'lib/navigation'
import { isGreaterThanOrEqual } from 'lib/semver'
import { Loader2 } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { MouseEvent, UIEvent, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { LoadingLine } from 'ui'
import { formatCronJobColumns } from './CronJobs.utils'
import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice'
import { CronJobsTabDataGrid } from './CronJobsTab.DataGrid'
import { CronJobsTabHeader } from './CronJobsTab.Header'
import { useCronJobsData } from './CronJobsTab.useCronJobsData'
import { DeleteCronJob } from './DeleteCronJob'
const EMPTY_CRON_JOB = { jobname: '', schedule: '', active: true, command: '' }
export const CronjobsTab = () => {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault(''))
const [search, setSearch] = useState(searchQuery)
const handleSearchSubmit = () => {
const trimmed = search.trim()
setSearchQuery(trimmed.length > 0 ? trimmed : null)
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery(null)
}
const { grid, count } = useCronJobsData({
projectRef: project?.ref,
connectionString: project?.connectionString,
searchQuery,
})
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true })
)
const [cronJobIdForEditing, setCronJobForEditing] = useQueryState('edit', parseAsString)
const cronJobForEditing = grid.rows.find((j) => j.jobid.toString() === cronJobIdForEditing)
const [, setCronJobForDeletion] = useQueryState('delete', parseAsString)
const { data: extensions = [] } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgCronExtension = extensions.find((ext) => ext.name === 'pg_cron')
const supportsSeconds = pgCronExtension?.installed_version
? isGreaterThanOrEqual(pgCronExtension.installed_version, '1.5')
: false
const { mutate: sendEvent } = useSendEventMutation()
const columns = useMemo(
() =>
formatCronJobColumns({
onSelectEdit: (job: CronJob) => {
sendEvent({
action: 'cron_job_update_clicked',
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
setCronJobForEditing(job.jobid.toString())
},
onSelectDelete: (job: CronJob) => {
sendEvent({
action: 'cron_job_delete_clicked',
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
setCronJobForDeletion(job.jobid.toString())
},
}),
[org?.slug, ref, sendEvent, setCronJobForEditing, setCronJobForDeletion]
)
const xScroll = useRef<number>(0)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
xScroll.current = event.currentTarget.scrollLeft
if (
grid.isLoading ||
grid.isFetchingNextPage ||
isScrollingHorizontally ||
!isAtBottom(event) ||
!grid.hasNextPage
) {
return
}
grid.fetchNextPage()
}
const onOpenCreateJobSheet = () => {
sendEvent({
action: 'cron_job_create_clicked',
groups: { project: project?.ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
setCreateCronJobSheetShown(true)
}
// Row click handler
const handleRowClick = (row: CronJob, event: MouseEvent<HTMLDivElement>) => {
const { jobid, jobname } = row
const url = `/project/${ref}/integrations/cron/jobs/${jobid}?child-label=${encodeURIComponent(
jobname || `Job #${jobid}`
)}`
sendEvent({
action: 'cron_job_history_clicked',
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
createNavigationHandler(url, router)(event)
}
const onClose = () => {
setCronJobForEditing(null)
setCreateCronJobSheetShown(false)
cleanPointerEventsNoneOnBody(500)
}
useEffect(() => {
if (grid.isSuccess && !!cronJobIdForEditing && !cronJobForEditing) {
toast('Cron job not found')
setCronJobForEditing(null)
}
}, [cronJobForEditing, cronJobIdForEditing, grid.isSuccess, setCronJobForEditing])
return (
<>
<div className="h-full w-full space-y-4">
<div className="h-full w-full flex flex-col relative">
<CronJobsTabHeader
search={search}
isRefreshing={grid.isRefetching && !grid.isFetchingNextPage}
onSearchChange={setSearch}
onSearchSubmit={handleSearchSubmit}
onClearSearch={handleClearSearch}
onRefresh={grid.refetch}
onCreateJob={onOpenCreateJobSheet}
/>
<LoadingLine loading={grid.isLoading || grid.isRefetching || grid.isFetchingNextPage} />
{grid.isMinimal && (
<CronJobRunDetailsOverflowNotice
queryCost={grid.queryCost}
refetchJobs={grid.refetch}
/>
)}
<CronJobsTabDataGrid
columns={columns}
rows={grid.rows}
isLoading={grid.isLoading}
error={grid.error}
searchQuery={searchQuery}
onScroll={handleScroll}
onRowClick={handleRowClick}
/>
<CronJobsFooter count={count} />
</div>
</div>
<DeleteCronJob />
<CreateCronJobSheet
open={!!createCronJobSheetShown || !!cronJobForEditing}
selectedCronJob={cronJobForEditing ?? EMPTY_CRON_JOB}
onClose={onClose}
/>
</>
)
}
// Footer component for displaying job count
interface CronJobsFooterProps {
count: {
value: number | undefined
isEstimate: boolean
isLoading: boolean
}
}
const CronJobsFooter = ({ count }: CronJobsFooterProps) => (
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
{count.isLoading ? (
<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" /> Loading...
</span>
) : (
`Total: ${count.value ?? 0} jobs${count.isEstimate ? ' (estimate)' : ''}`
)}
</div>
)