diff --git a/apps/docs/content/guides/auth/signing-keys.mdx b/apps/docs/content/guides/auth/signing-keys.mdx index 1810a3861f734..6532cf7341e59 100644 --- a/apps/docs/content/guides/auth/signing-keys.mdx +++ b/apps/docs/content/guides/auth/signing-keys.mdx @@ -171,7 +171,7 @@ If you wish to make your own JWTs or have access to the private key or shared se Use the [Supabase CLI](/docs/reference/cli/introduction) to quickly and securely generate a private key ready for import: ```sh -supabase gen generate-key ES256 +supabase gen signing-key --algorithm ES256 ``` Make sure you store this private key in a secure location, as it will not be extractable from Supabase. diff --git a/apps/docs/content/guides/cron/quickstart.mdx b/apps/docs/content/guides/cron/quickstart.mdx index 0ea18729a6a3d..cf1b2d6430b24 100644 --- a/apps/docs/content/guides/cron/quickstart.mdx +++ b/apps/docs/content/guides/cron/quickstart.mdx @@ -27,13 +27,6 @@ Attempting to create a second Job with the same name (and case) will overwrite t 4. Choose a schedule for your Job by inputting cron syntax (refer to the syntax chart in the form) or natural language. 5. Input SQL snippet or select a Database function, HTTP request, or Supabase Edge Function. -Cron Create - @@ -99,13 +92,6 @@ You can input seconds for your Job schedule interval as long as you're on Postgr 2. Click on the three vertical dots menu on the right side of the Job and click `Edit cron job`. 3. Make your changes and then click `Save cron job`. -Cron Edit - @@ -148,13 +134,6 @@ It is also possible to modify a job by using the `cron.schedule()` function by i 1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you'd like to unschedule. 2. Toggle the `Active`/`Inactive` switch next to Job name. -Cron Toggle - @@ -190,13 +169,6 @@ select cron.alter_job( 2. Click on the three vertical dots menu on the right side of the Job and click `Delete cron job`. 3. Confirm deletion by entering the Job name. -Cron Unschedule - @@ -227,13 +199,6 @@ Unscheduling a Job will permanently delete the Job from `cron.job` table but its 1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you want to see the runs of. 2. Click on the `History` button next to the Job name. -Cron Job Runs - diff --git a/apps/docs/public/img/guides/database/cron/cron-create.png b/apps/docs/public/img/guides/database/cron/cron-create.png deleted file mode 100644 index 0751b5c6186ac..0000000000000 Binary files a/apps/docs/public/img/guides/database/cron/cron-create.png and /dev/null differ diff --git a/apps/docs/public/img/guides/database/cron/cron-edit.png b/apps/docs/public/img/guides/database/cron/cron-edit.png deleted file mode 100644 index 632d4692a0e27..0000000000000 Binary files a/apps/docs/public/img/guides/database/cron/cron-edit.png and /dev/null differ diff --git a/apps/docs/public/img/guides/database/cron/cron-history.png b/apps/docs/public/img/guides/database/cron/cron-history.png deleted file mode 100644 index d65ef1f9a78a5..0000000000000 Binary files a/apps/docs/public/img/guides/database/cron/cron-history.png and /dev/null differ diff --git a/apps/docs/public/img/guides/database/cron/cron-toggle.png b/apps/docs/public/img/guides/database/cron/cron-toggle.png deleted file mode 100644 index 54a7c4a4936bc..0000000000000 Binary files a/apps/docs/public/img/guides/database/cron/cron-toggle.png and /dev/null differ diff --git a/apps/docs/public/img/guides/database/cron/cron-unschedule.png b/apps/docs/public/img/guides/database/cron/cron-unschedule.png deleted file mode 100644 index 10c6bb7275bf6..0000000000000 Binary files a/apps/docs/public/img/guides/database/cron/cron-unschedule.png and /dev/null differ diff --git a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx index 694d94dd4ee60..f9928a5b2788c 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx @@ -1,53 +1,8 @@ -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { X } from 'lucide-react' +import { Toggle } from 'ui' import { toast } from 'sonner' -import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge, Toggle } from 'ui' - import { useConsentState } from 'common' -import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage' import Panel from 'components/ui/Panel' import { useSendResetMutation } from 'data/telemetry/send-reset-mutation' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' - -export const TermsUpdateBanner = () => { - const [termsUpdateAcknowledged, setTermsUpdateAcknowledged, { isSuccess }] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.TERMS_OF_SERVICE_ACKNOWLEDGED, - false - ) - - if (!isSuccess || termsUpdateAcknowledged) return null - - return ( - - - - NOTICE - - Terms of Service Update – Effective Aug 1, 2025 - - - We’ve updated our{' '} - - Terms of Service - - . The new terms take effect on August 1, 2025 and reflect changes to support our evolving - business, legal requirements, and a new arbitration-based dispute resolution process. - Questions? Contact{' '} - - our team - - . - - } - className="absolute top-2 right-2 px-1" - onClick={() => setTermsUpdateAcknowledged(true)} - tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }} - /> - - ) -} export const AnalyticsSettings = () => { const { hasAccepted, acceptAll, denyAll, categories } = useConsentState() diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index e915e23641ddd..c1fcb13167482 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -36,7 +36,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) const [open, setOpen] = useState(false) const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() - const doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM'] + const doubleNegativeKeys = ['SMS_AUTOCONFIRM'] const canUpdateConfig: boolean = useCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx index 54edfebbf88a1..8961d29c60a62 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx @@ -64,7 +64,8 @@ const BasicAuthSettingsForm = () => { DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP, EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED, SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED, - MAILER_AUTOCONFIRM: authConfig.MAILER_AUTOCONFIRM, + // The backend uses false to represent that email confirmation is required + MAILER_AUTOCONFIRM: !authConfig.MAILER_AUTOCONFIRM, SITE_URL: authConfig.SITE_URL, }) } @@ -78,6 +79,9 @@ const BasicAuthSettingsForm = () => { payload.PASSWORD_REQUIRED_CHARACTERS = '' } + // The backend uses false to represent that email confirmation is required + payload.MAILER_AUTOCONFIRM = !values.MAILER_AUTOCONFIRM + updateAuthConfig( { projectRef: projectRef!, config: payload }, { diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx index ac1fee9298d88..92c05be3e02df 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx +++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx @@ -1,6 +1,5 @@ import dayjs from 'dayjs' import { Clipboard, Trash, UserIcon } from 'lucide-react' -import { UIEvent } from 'react' import { Column, useRowSelection } from 'react-data-grid' import { User } from 'data/auth/users-infinite-query' @@ -22,10 +21,6 @@ import { HeaderCell } from './UsersGridComponents' const GITHUB_AVATAR_URL = 'https://avatars.githubusercontent.com' const SUPPORTED_CSP_AVATAR_URLS = [GITHUB_AVATAR_URL, 'https://lh3.googleusercontent.com'] -export const isAtBottom = ({ currentTarget }: UIEvent): boolean => { - return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight -} - export const formatUsersData = (users: User[]) => { return users.map((user) => { const provider: string = (user.raw_app_meta_data?.provider as string) ?? '' diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 0593a02a59b30..2a142149b00e3 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -18,6 +18,7 @@ import { useUserDeleteMutation } from 'data/auth/user-delete-mutation' import { useUsersCountQuery } from 'data/auth/users-count-query' import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { isAtBottom } from 'lib/helpers' import { Button, cn, @@ -51,7 +52,7 @@ import { PROVIDER_FILTER_OPTIONS, USERS_TABLE_COLUMNS, } from './Users.constants' -import { formatUserColumns, formatUsersData, isAtBottom } from './Users.utils' +import { formatUserColumns, formatUsersData } from './Users.utils' export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous' diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx index c155b75005a2f..b68d93f3c9764 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx @@ -111,7 +111,7 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {

{extension.comment}

-
+
{extensionMeta?.github_url && ( - - -
-
-
-
-
- Schedule -
- -
-
-
- Last run -
-
- {lastRun ? ( - <> - - {data?.status && ( - - {data.status} - - )} - - ) : ( - 'Job has not been run yet' - )} -
-
-
-
- Next run -
-
- {nextRun ? ( - - ) : ( - 'Unable to parse next run for job' - )} -
-
-
-
- Command -
- code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11' - )} - /> -
-
-
-
-
- - showToggleConfirmationModal(false)} - variant={job.active ? 'destructive' : undefined} - onConfirm={() => { - toggleDatabaseCronJob({ - projectRef: selectedProject?.ref!, - connectionString: selectedProject?.connectionString, - jobId: job.jobid, - active: !job.active, - }) - showToggleConfirmationModal(false) - }} - > -

- {`Are you sure you want to ${job.active ? 'disable' : 'enable'} the`}{' '} - {`${job?.jobname}`} - cron job? -

-
- - ) -} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx new file mode 100644 index 0000000000000..8ea872bb67a55 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx @@ -0,0 +1,282 @@ +import dayjs from 'dayjs' +import { Clipboard, Edit, MoreVertical, Trash } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { useState } from 'react' +import { toast } from 'sonner' + +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' +import { + Badge, + Button, + cn, + CodeBlock, + ContextMenu_Shadcn_, + ContextMenuContent_Shadcn_, + ContextMenuItem_Shadcn_, + ContextMenuSeparator_Shadcn_, + ContextMenuTrigger_Shadcn_, + 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 { getNextRun } from './CronJobs.utils' + +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 { mutate: toggleDatabaseCronJob, isLoading: isToggling } = useDatabaseCronJobToggleMutation( + { + onSuccess: () => { + toast.success('Successfully asdasd') + setShowToggleModal(false) + }, + } + ) + + const onConfirmToggle = () => { + toggleDatabaseCronJob({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + jobId: jobid, + active: !active, + searchTerm: searchQuery, + }) + } + + if (col.id === 'actions') { + return ( +
+ + +
+ ) + } + + if (col.id === 'active') { + return ( + + e.stopPropagation()}> + + + e.stopPropagation()} + dialogOverlayProps={{ onClick: (e) => e.stopPropagation() }} + > + + {active ? 'Disable' : 'Enable'} cron job + + + +

+ Are you sure you want to {active ? 'disable' : 'enable'} the cron job "{jobname}"?{' '} +

+
+ + + + +
+
+ ) + } + + return ( + + +
+ {['latest_run', 'next_run'].includes(col.id) ? ( + col.id === 'latest_run' && formattedValue === null ? ( +

Job has not been run yet

+ ) : col.id === 'next_run' && !formattedValue ? ( +

Unable to parse next run for job

+ ) : ( + + ) + ) : col.id === 'command' ? ( + + +
+ {formattedValue} +
+
+ e.stopPropagation()} + > +

Command

+ code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11', + '[&>code]:text-xs' + )} + /> +
+
+ ) : ( +

+ {formattedValue} +

+ )} + {col.id === 'latest_run' && !!status && ( + + {status} + + )} +
+
+ e.stopPropagation()}> + e.stopPropagation()} + onSelect={() => copyToClipboard(formattedValue)} + > + + Copy {col.name.toLowerCase()} + + + e.stopPropagation()} + onSelect={() => onSelectEdit(row)} + > + + +
+ + Edit job +
+
+ {!jobname && ( + + This cron job doesn’t have a name and can’t be edited. Create a new one and delete + this job. + + )} +
+
+ + + + e.stopPropagation()} + onSelect={() => onSelectDelete(row)} + > + + Delete job + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx index 470a2c97d4a32..3399942fd4573 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx @@ -39,3 +39,13 @@ export const CRONJOB_DEFINITIONS = [ export type HTTPHeader = { name: string; value: string } export type HTTPParameter = { name: string; value: string } + +export const CRON_TABLE_COLUMNS = [ + { id: 'jobname', name: 'Name', minWidth: 0, width: 200 }, + { id: 'schedule', name: 'Schedule', width: 100 }, + { id: 'latest_run', name: 'Last run', width: 265 }, + { id: 'next_run', name: 'Next run', minWidth: 180 }, + { id: 'command', name: 'Command', minWidth: 320 }, + { id: 'active', name: 'Active', width: 70, minWidth: 70, maxWidth: 70 }, + { id: 'actions', name: '', minWidth: 75, width: 75 }, +] diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx similarity index 86% rename from apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index 1dbae3bfb44ce..c17360b7a0f2b 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -1,9 +1,13 @@ import parser from 'cron-parser' import { toString as CronToString } from 'cronstrue' import dayjs from 'dayjs' +import { Column } from 'react-data-grid' +import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' +import { cn } from 'ui' import { CronJobType } from './CreateCronJobSheet' -import { HTTPHeader } from './CronJobs.constants' +import { CRON_TABLE_COLUMNS, HTTPHeader } from './CronJobs.constants' +import { CronJobTableCell } from './CronJobTableCell' export function buildCronQuery(name: string, schedule: string, command: string) { const escapedName = name.replace(/'/g, "''") @@ -263,3 +267,46 @@ export const getNextRun = (schedule: string, lastRun?: string) => { } } } + +export const formatCronJobColumns = ({ + onSelectEdit, + onSelectDelete, +}: { + onSelectEdit: (job: CronJob) => void + onSelectDelete: (job: CronJob) => void +}) => { + return CRON_TABLE_COLUMNS.map((col) => { + const res: Column = { + key: col.id, + name: col.name, + minWidth: col.minWidth ?? 100, + maxWidth: col.maxWidth, + width: col.width, + resizable: false, + sortable: false, + draggable: false, + headerCellClass: undefined, + renderHeaderCell: () => { + return ( +
+

{col.name}

+
+ ) + }, + renderCell: ({ row }) => ( + + ), + } + return res + }) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 097293ccfc53e..b128ae77bbe96 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -1,30 +1,41 @@ -import { Search } from 'lucide-react' +import { Loader2, RefreshCw, Search, X } from 'lucide-react' +import { useRouter } from 'next/router' import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' -import { useState } from 'react' +import { UIEvent, useMemo, useRef, useState } from 'react' +import DataGrid, { DataGridHandle, Row } from 'react-data-grid' +import { useParams } from 'common' import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' +import { useCronJobsCountQuery } from 'data/database-cron-jobs/database-cron-jobs-count-query' +import { + CronJob, + useCronJobsInfiniteQuery, +} 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 { Button, Input, Sheet, SheetContent } from 'ui' -import { CronJobCard } from './CronJobCard' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { isAtBottom } from 'lib/helpers' +import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { formatCronJobColumns } from './CronJobs.utils' import { DeleteCronJob } from './DeleteCronJob' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -const EMPTY_CRON_JOB = { - jobname: '', - schedule: '', - active: true, - command: '', -} +const EMPTY_CRON_JOB = { jobname: '', schedule: '', active: true, command: '' } export const CronjobsTab = () => { + const router = useRouter() + const { ref } = useParams() const { project } = useProjectContext() - const org = useSelectedOrganization() + const { data: org } = useSelectedOrganizationQuery() + + const xScroll = useRef(0) + const gridRef = useRef(null) const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')) + const [search, setSearch] = useState(searchQuery) const [createCronJobSheetShown, setCreateCronJobSheetShown] = useQueryState( 'dialog-shown', parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true }) @@ -37,34 +48,79 @@ export const CronjobsTab = () => { >() const [cronJobForDeletion, setCronJobForDeletion] = useState() - const { data: cronJobs, isLoading } = useCronJobsQuery({ + const { + data, + error, + isLoading, + isError, + isRefetching, + isFetchingNextPage, + hasNextPage, + refetch, + fetchNextPage, + } = useCronJobsInfiniteQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + searchTerm: searchQuery, + }, + { keepPreviousData: Boolean(searchQuery), staleTime: Infinity } + ) + const cronJobs = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages]) + + const { data: count, isLoading: isLoadingCount } = useCronJobsCountQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const { data: extensions } = useDatabaseExtensionsQuery({ + const { data: extensions = [] } = useDatabaseExtensionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) const { mutate: sendEvent } = useSendEventMutation() + const columns = useMemo(() => { + return formatCronJobColumns({ + onSelectEdit: (job: any) => { + sendEvent({ + action: 'cron_job_update_clicked', + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + setCreateCronJobSheetShown(true) + setCronJobForEditing(job) + }, + onSelectDelete: (job: CronJob) => { + sendEvent({ + action: 'cron_job_delete_clicked', + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + setCronJobForDeletion(job) + }, + }) + }, [org?.slug, ref, sendEvent, setCreateCronJobSheetShown]) + // check pg_cron version to see if it supports seconds - const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron') + const pgCronExtension = extensions.find((ext) => ext.name === 'pg_cron') const installedVersion = pgCronExtension?.installed_version const supportsSeconds = installedVersion ? parseFloat(installedVersion) >= 1.5 : false - if (isLoading) - return ( -
- -
- ) + const handleScroll = (event: UIEvent) => { + const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft + xScroll.current = event.currentTarget.scrollLeft - const filteredCronJobs = - searchQuery.length > 0 - ? (cronJobs ?? []).filter((cj) => cj?.jobname?.includes(searchQuery || '')) - : cronJobs ?? [] + if ( + isLoading || + isFetchingNextPage || + isScrollingHorizontally || + !isAtBottom(event) || + !hasNextPage + ) { + return + } + + fetchNextPage() + } const onOpenCreateJobSheet = () => { sendEvent({ @@ -76,56 +132,131 @@ export const CronjobsTab = () => { return ( <> -
- {(cronJobs ?? []).length == 0 ? ( -
-

No cron jobs created yet

- -
- ) : ( -
-
- } - value={searchQuery || ''} - className="w-64" - onChange={(e) => setSearchQuery(e.target.value)} - /> +
+
+
+ } + value={search ?? ''} + onChange={(e) => setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.code === 'Enter') setSearchQuery(search.trim()) + }} + actions={[ + search && ( +
- {filteredCronJobs.length === 0 ? ( -
-

No results found

-

- Your search for "{searchQuery}" did not return any results -

-
- ) : isLoading ? ( -
+
+ + + + row.id} + rowClass={() => { + return cn( + 'cursor-pointer', + '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', + '[&>.rdg-cell:first-child>div]:ml-8' + ) + }} + onScroll={handleScroll} + renderers={{ + renderRow(_, props) { + return ( + { + const { jobid, jobname } = props.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', + }, + }) + + if (e.metaKey) { + window.open(url, '_blank') + } else { + router.push(url) + } + }} + /> + ) + }, + }} + /> + + {/* [Joshen] Render 0 rows state outside of the grid so that their position isn't relative to the grid scroll position */} + {cronJobs.length === 0 ? ( + isLoading ? ( +
+ ) : isError ? ( +
+ +
+ ) : ( +
+
+

+ {!!searchQuery ? 'No cron jobs found' : 'No cron jobs in your project'} +

+

+ {!!searchQuery + ? 'There are currently no cron jobs based on the search applied' + : 'There are currently no cron jobs created yet in your project'} +

+
+
+ ) + ) : null} + +
+ {isLoadingCount ? ( + + Loading... + ) : ( - filteredCronJobs.map((job) => ( - { - setCronJobForEditing(job) - setCreateCronJobSheetShown(true) - }} - onDeleteCronJob={(job) => setCronJobForDeletion(job)} - /> - )) + `Total: ${count} jobs` )}
- )} +
{ const { project } = useProjectContext() - const org = useSelectedOrganization() + const { data: org } = useSelectedOrganizationQuery() + const [searchQuery] = useQueryState('search', parseAsString.withDefault('')) const { mutate: sendEvent } = useSendEventMutation() const { mutate: deleteDatabaseCronJob, isLoading } = useDatabaseCronJobDeleteMutation({ @@ -37,6 +39,7 @@ export const DeleteCronJob = ({ cronJob, visible, onClose }: DeleteCronJobProps) jobId: cronJob.jobid, projectRef: project.ref, connectionString: project.connectionString, + searchTerm: searchQuery, }) } diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx index 844caa46664cb..b94f6ca2a4ce8 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx @@ -6,7 +6,7 @@ import DataGrid, { Column, Row } from 'react-data-grid' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' +import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query' import { CronJobRun, useCronJobRunsInfiniteQuery, @@ -138,9 +138,10 @@ export const PreviousRunsTab = () => { const jobId = Number(childId) - const { data: cronJobs, isLoading: isLoadingCronJobs } = useCronJobsQuery({ + const { data: job, isLoading: isLoadingCronJobs } = useCronJobQuery({ projectRef: project?.ref, connectionString: project?.connectionString, + id: jobId, }) const { @@ -177,7 +178,6 @@ export const PreviousRunsTab = () => { [fetchNextPage, isLoadingCronJobRuns] ) - const currentJobState = cronJobs?.find((job) => job.jobid === jobId) const cronJobRuns = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages]) return ( @@ -223,15 +223,13 @@ export const PreviousRunsTab = () => {

Schedule

- {currentJobState?.schedule ? ( + {job?.schedule ? ( <> - - {currentJobState.schedule.toLocaleLowerCase()} - + {job.schedule.toLocaleLowerCase()}

- {isSecondsFormat(currentJobState.schedule) + {isSecondsFormat(job.schedule) ? '' - : CronToString(currentJobState.schedule.toLowerCase())} + : CronToString(job.schedule.toLowerCase())}

) : ( @@ -249,7 +247,7 @@ export const PreviousRunsTab = () => { className="sql" parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md" > - {currentJobState?.command} + {job?.command}
@@ -259,7 +257,7 @@ export const PreviousRunsTab = () => { className="sql" parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md" > - {currentJobState?.command} + {job?.command} diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index a98c4ac0a2bb7..f4316b09b0488 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -32,6 +32,7 @@ export type MonacoEditorProps = { startLineNumber: number endLineNumber: number }) => void + placeholder?: string } const MonacoEditor = ({ @@ -39,6 +40,7 @@ const MonacoEditor = ({ editorRef, monacoRef, autoFocus = true, + placeholder = '', className, executeQuery, onHasSelection, @@ -204,6 +206,7 @@ const MonacoEditor = ({ options={{ tabSize: 2, fontSize: 13, + placeholder, lineDecorationsWidth: 0, readOnly: disableEdit, minimap: { enabled: false }, diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 0efecee1d4345..708e72eb52bde 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -718,6 +718,13 @@ export const SQLEditor = () => {
{ endLineNumber={promptState.endLineNumber} /> )} - - {!promptState.isOpen && !editorRef.current?.getValue() && ( - - Hit {os === 'macos' ? : `CTRL+`}K to edit with the - Assistant - - )} -
)} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx index 29e67a85e5763..5937daaddf5e3 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx @@ -1,3 +1,5 @@ +import { useParams } from 'common' +import Link from 'next/link' import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' export const ReadReplicasWarning = ({ latestPgVersion }: { latestPgVersion: string }) => { @@ -27,7 +29,7 @@ export const ObjectsToBeDroppedWarning = ({ A new version of Postgres is available
-

You'll need to remove the following objects before upgrading:

+

The following objects have to be removed before upgrading:

    {objectsToBeDropped.map((obj) => ( @@ -59,6 +61,8 @@ export const UnsupportedExtensionsWarning = ({ }: { unsupportedExtensions: string[] }) => { + const { ref } = useParams() + return ( A new version of Postgres is available
    -

    You'll need to remove the following extensions before upgrading:

    +

    The following extensions have to be removed before upgrading:

      {unsupportedExtensions.map((obj: string) => (
    • - {obj} + + {obj} +
    • ))}
    diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index f8722f68179f6..901e6ed8701c2 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -8,7 +8,7 @@ import { toast } from 'sonner' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' import { getAppStateSnapshot } from 'state/app-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' @@ -28,15 +28,14 @@ import { InnerSideBarFilterSortDropdown, InnerSideBarFilterSortDropdownItem, } from 'ui-patterns/InnerSideMenu' -import { SqlEditorMenuStaticLinks } from './SqlEditorMenuStaticLinks' import { SearchList } from './SQLEditorNavV2/SearchList' import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav' export const SQLEditorMenu = () => { const router = useRouter() - const { profile } = useProfile() - const project = useSelectedProject() const { ref } = useParams() + const { profile } = useProfile() + const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() const [search, setSearch] = useState('') @@ -153,14 +152,7 @@ export const SQLEditorMenu = () => {
    - {showSearch ? ( - - ) : ( - <> - - - - )} + {showSearch ? : }
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/CommunitySnippetsSection.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/CommunitySnippetsSection.tsx new file mode 100644 index 0000000000000..5466d8c7b2f97 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/CommunitySnippetsSection.tsx @@ -0,0 +1,61 @@ +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { BookText } from 'lucide-react' +import { useRouter } from 'next/router' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, +} from 'ui-patterns' +import { InnerSideMenuDataItem } from 'ui-patterns/InnerSideMenu' +import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants' + +const OPTIONS = ['templates', 'quickstarts'] as const + +export function CommunitySnippetsSection() { + const { ref } = useParams() + const router = useRouter() + + const [sectionVisibility, setSectionVisibility] = useLocalStorage( + LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(ref ?? ''), + DEFAULT_SECTION_STATE + ) + const { community: showCommunitySnippets } = sectionVisibility + + function isPageActive(key: string): boolean { + return router.asPath === `/project/${ref}/sql/${key}` + } + + return ( + { + setSectionVisibility({ + ...(sectionVisibility ?? DEFAULT_SECTION_STATE), + community: value, + }) + }} + > + + + {OPTIONS.map((pageId) => { + const active = isPageActive(pageId) + return ( + + + {pageId} + + ) + })} + + + ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts new file mode 100644 index 0000000000000..72f80c44725a1 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts @@ -0,0 +1,13 @@ +export type SectionState = { + shared: boolean + favorite: boolean + private: boolean + community: boolean +} + +export const DEFAULT_SECTION_STATE: SectionState = { + shared: false, + favorite: false, + private: true, + community: true, +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 55b8dee51f7a0..3a189539e115f 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -20,7 +20,7 @@ import { useSQLSnippetFoldersDeleteMutation } from 'data/content/sql-folders-del import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from 'data/content/sql-folders-query' import { useSqlSnippetsQuery } from 'data/content/sql-snippets-query' import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' import uuidv4 from 'lib/uuid' import { @@ -30,7 +30,7 @@ import { } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { SqlSnippets } from 'types' -import { Separator, TreeView } from 'ui' +import { TreeView } from 'ui' import { InnerSideBarEmptyPanel, InnerSideMenuCollapsible, @@ -39,7 +39,9 @@ import { InnerSideMenuSeparator, } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { CommunitySnippetsSection } from './CommunitySnippetsSection' import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets' +import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants' import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils' import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' @@ -47,18 +49,14 @@ interface SQLEditorNavProps { sort?: 'inserted_at' | 'name' } -type SectionState = { shared: boolean; favorite: boolean; private: boolean } -const DEFAULT_SECTION_STATE: SectionState = { shared: false, favorite: false, private: true } - export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const router = useRouter() const { ref: projectRef, id } = useParams() const { profile } = useProfile() - const project = useSelectedProject() - const snapV2 = useSqlEditorV2StateSnapshot() - + const { data: project } = useSelectedProjectQuery() const tabs = useTabsStateSnapshot() + const snapV2 = useSqlEditorV2StateSnapshot() const [sectionVisibility, setSectionVisibility] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''), @@ -545,6 +543,8 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { return ( <> + + { - + + + + + - {OPTIONS.map((pageId) => { - const active = isPageActive(pageId) - return ( - - {pageId} - - ) - })} -
- ) -} diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index 11cab9c709578..b763b61a6ff73 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -8,6 +8,7 @@ import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useTabsStateSnapshot, type Tab } from 'state/tabs' import { cn, TabsTrigger_Shadcn_ } from 'ui' +import { useEditorType } from '../editors/EditorsLayout.hooks' /** * Individual draggable tab component that handles: @@ -28,8 +29,9 @@ export const SortableTab = ({ openTabs: Tab[] onClose: (id: string) => void }) => { - const { selectedSchema: currentSchema } = useQuerySchemaState() + const editor = useEditorType() const tabs = useTabsStateSnapshot() + const { selectedSchema: currentSchema } = useQuerySchemaState() const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, }) @@ -44,8 +46,8 @@ export const SortableTab = ({ const shouldShowSchema = useMemo(() => { // For both table and schema tabs, show schema if: // Any tab has a different schema than the current schema parameter - return openTabs.some((t) => t.metadata?.schema !== currentSchema) - }, [openTabs, currentSchema]) + return openTabs.some((t) => editor === 'table' && t.metadata?.schema !== currentSchema) + }, [openTabs, currentSchema, editor]) // Create a motion version of TabsTrigger while preserving all functionality // const MotionTabsTrigger = motion(TabsTrigger_Shadcn_) diff --git a/apps/studio/data/database-cron-jobs/database-cron-job-query.ts b/apps/studio/data/database-cron-jobs/database-cron-job-query.ts new file mode 100644 index 0000000000000..cb78d893d6508 --- /dev/null +++ b/apps/studio/data/database-cron-jobs/database-cron-job-query.ts @@ -0,0 +1,55 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { executeSql } from 'data/sql/execute-sql-query' +import { ResponseError } from 'types' +import { CronJob } from './database-cron-jobs-infinite-query' +import { databaseCronJobsKeys } from './keys' + +export type DatabaseCronJobVariables = { + projectRef?: string + connectionString?: string | null + id?: number + name?: string +} + +export async function getDatabaseCronJob({ + projectRef, + connectionString, + id, + name, +}: DatabaseCronJobVariables) { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: !!id + ? `SELECT * FROM cron.job where jobid = ${id};` + : `SELECT * FROM cron.job where jobname = '${name}';`, + queryKey: ['cron-job', id], + }) + + return result[0] +} + +export type DatabaseCronJobData = CronJob +export type DatabaseCronJobError = ResponseError + +export const useCronJobQuery = ( + { projectRef, connectionString, id, name }: DatabaseCronJobVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + databaseCronJobsKeys.job(projectRef, id ?? name), + () => getDatabaseCronJob({ projectRef, connectionString, id }), + { + enabled: + enabled && + typeof projectRef !== 'undefined' && + (typeof id !== 'undefined' || typeof name !== 'undefined'), + ...options, + } + ) diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-count-query.ts similarity index 52% rename from apps/studio/data/database-cron-jobs/database-cron-jobs-query.ts rename to apps/studio/data/database-cron-jobs/database-cron-jobs-count-query.ts index 0217f66430127..70ad18caa62f9 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-query.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-count-query.ts @@ -3,52 +3,41 @@ import { executeSql } from 'data/sql/execute-sql-query' import { ResponseError } from 'types' import { databaseCronJobsKeys } from './keys' -export type DatabaseCronJobsVariables = { +type DatabaseCronJobsCountVariables = { projectRef?: string connectionString?: string | null } -export type CronJob = { - jobid: number - schedule: string - command: string - nodename: string - nodeport: number - database: string - username: string - active: boolean - jobname: string | null -} - -const cronJobSqlQuery = `select * from cron.job order by jobid;` +const cronJobCountSql = `select count(jobid) from cron.job;`.trim() -export async function getDatabaseCronJobs({ +export async function getDatabaseCronJobsCount({ projectRef, connectionString, -}: DatabaseCronJobsVariables) { +}: DatabaseCronJobsCountVariables) { if (!projectRef) throw new Error('Project ref is required') const { result } = await executeSql({ projectRef, connectionString, - sql: cronJobSqlQuery, + sql: cronJobCountSql, + queryKey: ['cron-jobs-count'], }) - return result + return result[0].count } -export type DatabaseCronJobData = CronJob[] +export type DatabaseCronJobData = number export type DatabaseCronJobError = ResponseError -export const useCronJobsQuery = ( - { projectRef, connectionString }: DatabaseCronJobsVariables, +export const useCronJobsCountQuery = ( + { projectRef, connectionString }: DatabaseCronJobsCountVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( - databaseCronJobsKeys.list(projectRef), - () => getDatabaseCronJobs({ projectRef, connectionString }), + databaseCronJobsKeys.count(projectRef), + () => getDatabaseCronJobsCount({ projectRef, connectionString }), { enabled: enabled && typeof projectRef !== 'undefined', ...options, diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts index 0424703a2dce9..2e7b2396856c4 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts @@ -9,6 +9,7 @@ export type DatabaseCronJobCreateVariables = { projectRef: string connectionString?: string | null query: string + searchTerm?: string } export async function createDatabaseCronJob({ @@ -42,8 +43,10 @@ export const useDatabaseCronJobCreateMutation = ({ (vars) => createDatabaseCronJob(vars), { async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries(databaseCronJobsKeys.list(projectRef)) + const { projectRef, searchTerm } = variables + await queryClient.invalidateQueries( + databaseCronJobsKeys.listInfinite(projectRef, searchTerm) + ) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts index 13fa2686e41a0..d38414ec968d9 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts @@ -9,6 +9,7 @@ export type DatabaseCronJobDeleteVariables = { projectRef: string connectionString?: string | null jobId: number + searchTerm?: string } export async function deleteDatabaseCronJob({ @@ -42,8 +43,10 @@ export const useDatabaseCronJobDeleteMutation = ({ (vars) => deleteDatabaseCronJob(vars), { async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries(databaseCronJobsKeys.list(projectRef)) + const { projectRef, searchTerm } = variables + await queryClient.invalidateQueries( + databaseCronJobsKeys.listInfinite(projectRef, searchTerm) + ) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts new file mode 100644 index 0000000000000..861bdde9d1d15 --- /dev/null +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts @@ -0,0 +1,105 @@ +import { UseInfiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query' + +import { executeSql } from 'data/sql/execute-sql-query' +import { ResponseError } from 'types' +import { databaseCronJobsKeys } from './keys' + +const CRON_JOBS_PAGE_LIMIT = 20 + +type DatabaseCronJobRunsVariables = { + projectRef?: string + connectionString?: string | null + searchTerm?: string +} + +export type CronJob = { + jobid: number + jobname: string | null + active: boolean + command: string + latest_run: string + schedule: string + status: string +} + +const getCronJobSql = ({ searchTerm, page }: { searchTerm?: string; page: number }) => + ` +WITH latest_runs AS ( + SELECT + jobid, + status, + MAX(start_time) AS latest_run + FROM cron.job_run_details + GROUP BY jobid, status +) +SELECT + job.jobid, + job.jobname, + job.schedule, + job.command, + job.active, + lr.latest_run, + lr.status +FROM + cron.job job +LEFT JOIN latest_runs lr ON job.jobid = lr.jobid +${!!searchTerm ? `WHERE job.jobname ILIKE '%${searchTerm}%'` : ''} +ORDER BY job.jobid +LIMIT ${CRON_JOBS_PAGE_LIMIT} +OFFSET ${page * CRON_JOBS_PAGE_LIMIT}; +`.trim() + +export async function getDatabaseCronJobs({ + projectRef, + connectionString, + searchTerm, + page = 0, +}: DatabaseCronJobRunsVariables & { page: number }) { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: getCronJobSql({ searchTerm, page }), + queryKey: ['cron-jobs'], + }) + + return result +} + +type DatabaseCronJobsInfiniteData = CronJob[] +type DatabaseCronJobsInfiniteError = ResponseError + +export const useCronJobsInfiniteQuery = ( + { projectRef, connectionString, searchTerm }: DatabaseCronJobRunsVariables, + { + enabled = true, + ...options + }: UseInfiniteQueryOptions< + DatabaseCronJobsInfiniteData, + DatabaseCronJobsInfiniteError, + TData + > = {} +) => + useInfiniteQuery( + databaseCronJobsKeys.listInfinite(projectRef, searchTerm), + ({ pageParam }) => { + return getDatabaseCronJobs({ + projectRef, + connectionString, + searchTerm, + page: pageParam, + }) + }, + { + staleTime: 0, + enabled: enabled && typeof projectRef !== 'undefined', + getNextPageParam(lastPage, pages) { + const page = pages.length + const hasNextPage = lastPage.length >= CRON_JOBS_PAGE_LIMIT + if (!hasNextPage) return undefined + return page + }, + ...options, + } + ) diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-run-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-run-query.ts deleted file mode 100644 index 04d935db66746..0000000000000 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-run-query.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' - -import { executeSql } from 'data/sql/execute-sql-query' -import { ResponseError } from 'types' -import { CronJobRun } from './database-cron-jobs-runs-infinite-query' -import { databaseCronJobsKeys } from './keys' - -export type DatabaseCronJobRunVariables = { - projectRef?: string - connectionString?: string | null - jobId: number -} - -export async function getDatabaseCronJobRun({ - projectRef, - connectionString, - jobId, -}: DatabaseCronJobRunVariables) { - if (!projectRef) throw new Error('Project ref is required') - - const query = ` - SELECT * FROM cron.job_run_details - WHERE jobid = '${jobId}' - ORDER BY start_time DESC - LIMIT 1` - - const { result } = await executeSql({ - projectRef, - connectionString, - sql: query, - }) - - return result[0] ?? null -} - -export type DatabaseCronJobRunData = CronJobRun | null -export type DatabaseCronJobRunError = ResponseError - -export const useCronJobRunQuery = ( - { projectRef, connectionString, jobId }: DatabaseCronJobRunVariables, - { - enabled = true, - ...options - }: UseQueryOptions = {} -) => - useQuery( - databaseCronJobsKeys.run(projectRef, jobId), - () => getDatabaseCronJobRun({ projectRef, connectionString, jobId }), - { - enabled: enabled && typeof projectRef !== 'undefined', - ...options, - } - ) diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts index 680ac4a5562a3..d7fb6aee2af0f 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts @@ -51,17 +51,17 @@ export async function getDatabaseCronJobRuns({ return result } -export type DatabaseCronJobData = CronJobRun[] -export type DatabaseCronJobError = ResponseError +type DatabaseCronJobRunData = CronJobRun[] +type DatabaseCronJobError = ResponseError -export const useCronJobRunsInfiniteQuery = ( +export const useCronJobRunsInfiniteQuery = ( { projectRef, connectionString, jobId }: DatabaseCronJobRunsVariables, { enabled = true, ...options - }: UseInfiniteQueryOptions = {} + }: UseInfiniteQueryOptions = {} ) => - useInfiniteQuery( + useInfiniteQuery( databaseCronJobsKeys.runsInfinite(projectRef, jobId, { status }), ({ pageParam }) => { return getDatabaseCronJobRuns({ diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-toggle-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-toggle-mutation.ts index b7f8471bafebb..da8ec6780fd21 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-toggle-mutation.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-toggle-mutation.ts @@ -10,6 +10,7 @@ export type DatabaseCronJobToggleVariables = { connectionString?: string | null jobId: number active: boolean + searchTerm?: string } export async function toggleDatabaseCronJob({ @@ -44,8 +45,10 @@ export const useDatabaseCronJobToggleMutation = ({ (vars) => toggleDatabaseCronJob(vars), { async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries(databaseCronJobsKeys.list(projectRef)) + const { projectRef, searchTerm } = variables + await queryClient.invalidateQueries( + databaseCronJobsKeys.listInfinite(projectRef, searchTerm) + ) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/database-cron-jobs/keys.ts b/apps/studio/data/database-cron-jobs/keys.ts index 60f75b20a6503..6a6505fb085d8 100644 --- a/apps/studio/data/database-cron-jobs/keys.ts +++ b/apps/studio/data/database-cron-jobs/keys.ts @@ -2,7 +2,12 @@ export const databaseCronJobsKeys = { create: () => ['cron-jobs', 'create'] as const, delete: () => ['cron-jobs', 'delete'] as const, alter: () => ['cronjobs', 'alter'] as const, - list: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs'] as const, + job: (projectRef: string | undefined, identifier: number | string | undefined) => + ['projects', projectRef, 'cron-jobs', identifier] as const, + listInfinite: (projectRef: string | undefined, searchTerm: string | undefined) => + ['projects', projectRef, 'cron-jobs', { searchTerm }] as const, + count: (projectRef: string | undefined) => + ['projects', projectRef, 'cron-jobs', 'count'] as const, run: (projectRef: string | undefined, jobId: number) => [ 'projects', projectRef, diff --git a/apps/studio/data/database-extensions/database-extension-disable-mutation.ts b/apps/studio/data/database-extensions/database-extension-disable-mutation.ts index 241be5740bd9f..7f1723db36518 100644 --- a/apps/studio/data/database-extensions/database-extension-disable-mutation.ts +++ b/apps/studio/data/database-extensions/database-extension-disable-mutation.ts @@ -2,6 +2,7 @@ import pgMeta from '@supabase/pg-meta' import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { configKeys } from 'data/config/keys' import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError } from 'types' import { databaseExtensionsKeys } from './keys' @@ -56,7 +57,10 @@ export const useDatabaseExtensionDisableMutation = ({ >((vars) => disableDatabaseExtension(vars), { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(databaseExtensionsKeys.list(projectRef)) + await Promise.all([ + queryClient.invalidateQueries(databaseExtensionsKeys.list(projectRef)), + queryClient.invalidateQueries(configKeys.upgradeEligibility(projectRef)), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index d645e90be8f84..2fab08b83d579 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -1,7 +1,12 @@ export { default as passwordStrength } from './password-strength' export { default as uuidv4 } from './uuid' +import { UIEvent } from 'react' import type { TablesData } from '../data/tables/tables-query' +export const isAtBottom = ({ currentTarget }: UIEvent): boolean => { + return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight +} + export const tryParseJson = (jsonString: any) => { try { const parsed = JSON.parse(jsonString) diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index df017d02ba52b..7e8a9767a98d6 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -12,7 +12,6 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { PROJECT_STATUS } from 'lib/constants' import type { NextPageWithLayout } from 'types' import { Admonition } from 'ui-patterns' -import { TermsUpdateBanner } from 'components/interfaces/Account/Preferences/AnalyticsSettings' const ProjectsPage: NextPageWithLayout = () => { const org = useSelectedOrganization() @@ -39,7 +38,6 @@ const ProjectsPage: NextPageWithLayout = () => { ) : (
- { const router = useRouter() @@ -64,7 +63,6 @@ const OrganizationsPage: NextPageWithLayout = () => { )} - Your Organizations {organizations.length === 0 && orgNotFound && ( diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx index b76303b8010c1..893eee8c440c6 100644 --- a/apps/studio/pages/project/[ref]/sql/index.tsx +++ b/apps/studio/pages/project/[ref]/sql/index.tsx @@ -6,7 +6,6 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' -import { NewTab } from 'components/layouts/Tabs/NewTab' import { useAppStateSnapshot } from 'state/app-state' import { useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' @@ -26,10 +25,13 @@ const TableEditorPage: NextPageWithLayout = () => { } else if (lastTabId) { const lastTab = store.tabsMap[lastTabId] if (lastTab) router.push(`/project/${projectRef}/sql/${lastTab.id.replace('sql-', '')}`) + } else { + router.push(`/project/${projectRef}/sql/new`) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - return + return null } TableEditorPage.getLayout = (page) => ( diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index a374533acc3af..449ea259bb9fc 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -18,7 +18,6 @@ export const LOCAL_STORAGE_KEYS = { NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge', TABS_INTERFACE_ACKNOWLEDGED: 'tabs-interface-acknowledge', - TERMS_OF_SERVICE_ACKNOWLEDGED: 'terms-of-service-acknowledged', AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in', DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`,