-
-
-
{name}
- {status && (
-
- {status}
+ if (featured) {
+ return (
+
+
+ {/* Full-width image/icon at the top */}
+
+ {image ? (
+
+ ) : (
+
+ {icon({ className: 'w-full h-full text-foreground' })}
+
+ )}
+
+
+
+
{name}
+
{description}
+
+ {status && (
+
+ {status}
+
+ )}
+
+ Official
- )}
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {icon()}
-
{description}
-
-
-
- Official
-
{isInstalled && (
-
- Installed
+
+ Installed
)}
-
-
+
+
{name}
+
+
{description}
+
+ {status && (
+
+ {status}
+
+ )}
+
+ Official
+
+
+
+
+
)
}
diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
index 528a6438755e4..2e10655b7f4d8 100644
--- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
+++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
@@ -87,7 +87,7 @@ const supabaseIntegrations: IntegrationDefinition[] = [
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
if (childId) {
- return dynamic(() => import('../Queues/QueueTab').then((mod) => mod.QueueTab), {
+ return dynamic(() => import('../Queues/QueuePage').then((mod) => mod.QueuePage), {
loading: Loading,
})
}
@@ -121,7 +121,7 @@ const supabaseIntegrations: IntegrationDefinition[] = [
icon: ({ className, ...props } = {}) => (
),
- description: 'Schedule recurring Jobs in Postgres.',
+ description: 'Schedule recurring Jobs in Postgres',
docsUrl: 'https://github.com/citusdata/pg_cron',
author: {
name: 'Citus Data',
@@ -143,12 +143,9 @@ const supabaseIntegrations: IntegrationDefinition[] = [
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
if (childId) {
- return dynamic(
- () => import('../CronJobs/PreviousRunsTab').then((mod) => mod.PreviousRunsTab),
- {
- loading: Loading,
- }
- )
+ return dynamic(() => import('../CronJobs/CronJobPage').then((mod) => mod.CronJobPage), {
+ loading: Loading,
+ })
}
switch (pageId) {
case 'overview':
diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueueCells.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueueCells.tsx
new file mode 100644
index 0000000000000..a8d5179608433
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Queues/QueueCells.tsx
@@ -0,0 +1,87 @@
+import dayjs from 'dayjs'
+import { Check, Loader2, X } from 'lucide-react'
+
+import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query'
+import { PostgresQueue } from 'data/database-queues/database-queues-query'
+import { useTablesQuery } from 'data/tables/tables-query'
+import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { DATETIME_FORMAT } from 'lib/constants'
+
+export interface QueueWithMetrics extends PostgresQueue {
+ id: string // Add unique id for DataGrid
+}
+
+interface QueueCellProps {
+ queue: QueueWithMetrics
+}
+
+export const QueueNameCell = ({ queue }: QueueCellProps) => (
+
+
+ {queue.queue_name}
+
+
+)
+
+export const QueueTypeCell = ({ queue }: QueueCellProps) => {
+ const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic'
+ return (
+
+
+ {type}
+
+
+ )
+}
+
+export const QueueRLSCell = ({ queue }: QueueCellProps) => {
+ const { data: selectedProject } = useSelectedProjectQuery()
+
+ const { data: queueTables } = useTablesQuery({
+ projectRef: selectedProject?.ref,
+ connectionString: selectedProject?.connectionString,
+ schema: 'pgmq',
+ })
+
+ const queueTable = queueTables?.find((x) => x.name === `q_${queue.queue_name}`)
+ const isRlsEnabled = !!queueTable?.rls_enabled
+
+ return (
+
+ {isRlsEnabled ? : }
+
+ )
+}
+
+export const QueueCreatedAtCell = ({ queue }: QueueCellProps) => (
+
+ {dayjs(queue.created_at).format(DATETIME_FORMAT)}
+
+)
+
+export const QueueSizeCell = ({ queue }: QueueCellProps) => {
+ const { data: selectedProject } = useSelectedProjectQuery()
+
+ const { data: metrics, isLoading } = useQueuesMetricsQuery(
+ {
+ queueName: queue.queue_name,
+ projectRef: selectedProject?.ref,
+ connectionString: selectedProject?.connectionString,
+ },
+ {
+ staleTime: 30 * 1000, // 30 seconds
+ }
+ )
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ {metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null}
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuePage.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuePage.tsx
new file mode 100644
index 0000000000000..c015278f8bb5d
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Queues/QueuePage.tsx
@@ -0,0 +1,64 @@
+import dayjs from 'dayjs'
+import { useRouter } from 'next/router'
+
+import { useParams } from 'common'
+import { NavigationItem, PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { useQueuesQuery } from 'data/database-queues/database-queues-query'
+import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { DATETIME_FORMAT } from 'lib/constants'
+import { QueueTab } from './QueueTab'
+
+export const QueuePage = () => {
+ const router = useRouter()
+ const { ref, id, pageId, childId } = useParams()
+ const childLabel = router?.query?.['child-label'] as string
+ const { data: project } = useSelectedProjectQuery()
+
+ const { data: queues } = useQueuesQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+
+ const currentQueue = queues?.find((queue) => queue.queue_name === childId)
+
+ const breadcrumbItems = [
+ {
+ label: 'Integrations',
+ href: `/project/${ref}/integrations`,
+ },
+ {
+ label: 'Queues',
+ href: pageId
+ ? `/project/${ref}/integrations/${id}/${pageId}`
+ : `/project/${ref}/integrations/${id}`,
+ },
+ {
+ label: childId,
+ },
+ ]
+
+ const navigationItems: NavigationItem[] = []
+
+ const pageTitle = childLabel || childId || 'Queue'
+
+ const getQueueType = (queue: typeof currentQueue) => {
+ if (!queue) return 'Unknown'
+ return queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic'
+ }
+
+ const pageSubtitle = currentQueue
+ ? `${getQueueType(currentQueue)} queue created on ${dayjs(currentQueue.created_at).format(DATETIME_FORMAT)}`
+ : undefined
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx
index f595ec0faa0b4..fe207c0c946a4 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx
@@ -4,8 +4,8 @@ import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
-import DeleteQueue from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue'
-import PurgeQueue from 'components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue'
+import { DeleteQueue } from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue'
+import { PurgeQueue } from 'components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue'
import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/SingleQueue/Queue.utils'
import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid'
import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters'
@@ -100,7 +100,8 @@ export const QueueTab = () => {
return (
-
+
+
@@ -238,8 +239,8 @@ You may opt to manage your queues via any Supabase client libraries or PostgREST
-
+
[] => {
+ return [
+ {
+ key: 'queue_name',
+ name: 'Name',
+ resizable: true,
+ minWidth: 200,
+ headerCellClass: undefined,
+ renderHeaderCell: () => {
+ return (
+
+ )
+ },
+ renderCell: (props) => {
+ return
+ },
+ },
+ {
+ key: 'type',
+ name: 'Type',
+ resizable: true,
+ minWidth: 120,
+ headerCellClass: undefined,
+ renderHeaderCell: () => {
+ return (
+
+ )
+ },
+ renderCell: (props) => {
+ return
+ },
+ },
+ {
+ key: 'rls_enabled',
+ name: 'RLS enabled',
+ resizable: true,
+ minWidth: 120,
+ headerCellClass: undefined,
+ renderHeaderCell: () => {
+ return (
+
+ )
+ },
+ renderCell: (props) => {
+ return
+ },
+ },
+ {
+ key: 'created_at',
+ name: 'Created at',
+ resizable: true,
+ minWidth: 180,
+ headerCellClass: undefined,
+ renderHeaderCell: () => {
+ return (
+
+ )
+ },
+ renderCell: (props) => {
+ return
+ },
+ },
+ {
+ key: 'queue_size',
+ name: 'Size',
+ resizable: true,
+ minWidth: 120,
+ headerCellClass: undefined,
+ renderHeaderCell: () => {
+ return (
+
+ )
+ },
+ renderCell: (props) => {
+ return
+ },
+ },
+ ]
+}
+
+export const prepareQueuesForDataGrid = (queues: PostgresQueue[]): QueueWithMetrics[] => {
+ return queues.map((queue) => ({
+ ...queue,
+ id: queue.queue_name, // Use queue_name as unique id
+ }))
+}
diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx
index f3be8b1f7823c..60ffde22d84c3 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx
@@ -1,19 +1,27 @@
-import { Search } from 'lucide-react'
-import { useQueryState } from 'nuqs'
-import { useState } from 'react'
+import { RefreshCw, Search, X } from 'lucide-react'
+import { useRouter } from 'next/router'
+import { parseAsString, useQueryState } from 'nuqs'
+import { useMemo, useState } from 'react'
+import DataGrid, { Row } from 'react-data-grid'
-import Table from 'components/to-be-cleaned/Table'
+import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useQueuesQuery } from 'data/database-queues/database-queues-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { Button, Input, Sheet, SheetContent } from 'ui'
+import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui'
+import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateQueueSheet } from './CreateQueueSheet'
-import { QueuesRows } from './QueuesRows'
+import { formatQueueColumns, prepareQueuesForDataGrid } from './Queues.utils'
export const QueuesTab = () => {
+ const router = useRouter()
+ const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
+ const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault(''))
+ const [search, setSearch] = useState(searchQuery)
+
// used for confirmation prompt in the Create Queue Sheet
const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false)
const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false)
@@ -23,77 +31,135 @@ export const QueuesTab = () => {
error,
isLoading,
isError,
+ isRefetching,
+ refetch,
} = useQueuesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
- const [searchQuery, setSearchQuery] = useQueryState('search')
-
- if (isLoading)
- return (
-
-
-
- )
- if (isError)
- return (
-
+ // Filter queues based on search query
+ const filteredQueues = useMemo(() => {
+ if (!queues) return []
+ if (!searchQuery) return queues
+ return queues.filter((queue) =>
+ queue.queue_name.toLowerCase().includes(searchQuery.toLowerCase())
)
+ }, [queues, searchQuery])
+
+ // Prepare queues for DataGrid
+ const queueData = useMemo(() => prepareQueuesForDataGrid(filteredQueues), [filteredQueues])
+
+ // Get columns configuration
+ const columns = useMemo(() => formatQueueColumns(), [])
return (
<>
-
- {queues.length === 0 ? (
-
-
No queues 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 && (
+
}
+ onClick={() => {
+ setSearch('')
+ setSearchQuery('')
+ }}
+ className="p-0 h-5 w-5"
+ />
+ ),
+ ]}
+ />
-
+
+ }
+ loading={isRefetching}
+ onClick={() => refetch()}
+ >
+ Refresh
+
+
+
-
- Name
-
- Type
-
-
- RLS enabled
-
-
- Created at
-
-
- Size
-
-
- >
- }
- body={}
- />
+
+
+ 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'
+ )
+ }}
+ renderers={{
+ renderRow(_, props) {
+ return (
+ {
+ const { queue_name } = props.row
+ const url = `/project/${ref}/integrations/queues/queues/${queue_name}`
+ router.push(url)
+ }}
+ />
+ )
+ },
+ }}
+ />
+
+ {/* Render 0 rows state outside of the grid */}
+ {queueData.length === 0 ? (
+ isLoading ? (
+
+
+
+ ) : isError ? (
+
+ ) : (
+
+
+
+ {!!searchQuery ? 'No queues found' : 'No queues created yet'}
+
+
+ {!!searchQuery
+ ? 'There are currently no queues based on the search applied'
+ : 'There are currently no queues created yet in your project'}
+
+
+
+ )
+ ) : null}
+
+
+ {`Total: ${queueData.length} queues`}
- )}
+
setIsClosingCreateQueueSheet(true)}>
diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx
index f572987e5ee33..e953d0b49ed05 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx
@@ -11,7 +11,7 @@ interface DeleteQueueProps {
onClose: () => void
}
-const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => {
+export const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
@@ -58,5 +58,3 @@ const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => {
/>
)
}
-
-export default DeleteQueue
diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx
index b7c9ad924a9af..d6f757d3e2ee7 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx
@@ -1,4 +1,3 @@
-import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useDatabaseQueuePurgeMutation } from 'data/database-queues/database-queues-purge-mutation'
@@ -11,14 +10,12 @@ interface PurgeQueueProps {
onClose: () => void
}
-const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => {
- const router = useRouter()
+export const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => {
const { data: project } = useSelectedProjectQuery()
const { mutate: purgeDatabaseQueue, isLoading } = useDatabaseQueuePurgeMutation({
onSuccess: () => {
toast.success(`Successfully purged queue ${queueName}`)
- router.push(`/project/${project?.ref}/integrations/queues`)
onClose()
},
})
@@ -61,5 +58,3 @@ const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => {
/>
)
}
-
-export default PurgeQueue
diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx
index cb57532557c00..c57020fd0c732 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx
@@ -5,12 +5,12 @@ import { parseAsInteger, useQueryState } from 'nuqs'
import { UIEvent, useMemo, useRef } from 'react'
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
+import AlertError from 'components/ui/AlertError'
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
+import { ResponseError } from 'types'
import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel'
-import { ResponseError } from 'types'
-import AlertError from 'components/ui/AlertError'
interface QueueDataGridProps {
error?: ResponseError | null
@@ -60,14 +60,20 @@ const messagesCols = [
if (row.archived_at) {
return (
- Archived at {dayjs(row.archived_at).format(DATE_FORMAT)}
+
+
+ Archived at {dayjs(row.archived_at).format(DATE_FORMAT)}
+
+
)
}
return (
-
- {isAvailable ? 'Available ' : `Available at ${dayjs(row.vt).format(DATE_FORMAT)}`}
-
+
+
+ {isAvailable ? 'Available ' : `Available at ${dayjs(row.vt).format(DATE_FORMAT)}`}
+
+
)
},
},
@@ -77,14 +83,22 @@ const messagesCols = [
description: undefined,
minWidth: 50,
width: 70,
- value: (row: PostgresQueueMessage) => {row.read_ct},
+ value: (row: PostgresQueueMessage) => (
+
+ {row.read_ct}
+
+ ),
},
{
id: 'payload',
name: 'Payload',
description: undefined,
minWidth: 600,
- value: (row: PostgresQueueMessage) => {JSON.stringify(row.message)},
+ value: (row: PostgresQueueMessage) => (
+
+ {JSON.stringify(row.message)}
+
+ ),
},
]
@@ -95,31 +109,23 @@ const columns = messagesCols.map((col) => {
resizable: true,
minWidth: col.minWidth ?? 120,
width: col.width,
- headerCellClass: 'first:pl-6 cursor-pointer',
+ headerCellClass: undefined,
renderHeaderCell: () => {
- return (
-
-
-
{col.name}
- {col.description &&
{col.description}
}
-
-
- )
- },
- renderCell: (props) => {
- const value = col.value(props.row)
-
return (
)
},
+ renderCell: (props) => {
+ const value = col.value(props.row)
+ return value
+ },
}
return result
})
@@ -156,14 +162,12 @@ export const QueueMessagesDataGrid = ({
columns={columns}
onScroll={handleScroll}
rows={messages}
- rowClass={(message) => {
- const isSelected = message.msg_id === selectedMessageId
- return [
- `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
- `${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`,
+ rowClass={() => {
+ return cn(
+ 'cursor-pointer',
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
- '[&>.rdg-cell:first-child>div]:ml-4',
- ].join(' ')
+ '[&>.rdg-cell:first-child>div]:ml-8'
+ )
}}
renderers={{
renderRow(idx, props) {
diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx
index 2fd77da9157b5..6b4b1dc686330 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx
@@ -8,22 +8,18 @@ interface QueueFiltersProps {
export const QueueFilters = ({ selectedTypes, setSelectedTypes }: QueueFiltersProps) => {
return (
-
-
- setSelectedTypes(value as any)}
- />
-
-
+ setSelectedTypes(value as any)}
+ />
)
}
diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx
index 713b36031dca3..12b22e64bba0d 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx
@@ -1,13 +1,13 @@
import { zodResolver } from '@hookform/resolvers/zod'
+import { useEffect } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
+import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { useDatabaseQueueMessageSendMutation } from 'data/database-queues/database-queue-messages-send-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { useEffect } from 'react'
-import { toast } from 'sonner'
import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Modal } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
@@ -121,6 +121,7 @@ export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) =>
field.onChange(e)}
options={{ wordWrap: 'off', contextmenu: false }}
diff --git a/apps/studio/components/layouts/Integrations/header.tsx b/apps/studio/components/layouts/Integrations/header.tsx
deleted file mode 100644
index 320406c222bb0..0000000000000
--- a/apps/studio/components/layouts/Integrations/header.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import { AnimatePresence, motion, useScroll, useTransform } from 'framer-motion'
-import { ChevronLeft } from 'lucide-react'
-import { useRouter } from 'next/compat/router'
-import Link from 'next/link'
-import { forwardRef, useRef } from 'react'
-
-import { useParams } from 'common'
-import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
-import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { Badge, cn } from 'ui'
-
-interface HeaderProps {
- scroll: ReturnType
-}
-
-export const Header = forwardRef(({ scroll }, ref) => {
- const router = useRouter()
- const { id } = useParams()
- // Get project context
- const { data: project } = useSelectedProjectQuery()
- // Find the integration details based on ID
- const integration = INTEGRATIONS.find((i) => i.id === id)
- // Check if we're on the main integrations page
- const isIntegrationsHome = !id
-
- const layoutTransition = { duration: 0.15 }
-
- const headerRef = useRef(null)
-
- // Input range: The scrollY range for triggering the animation (e.g., 0 to 200px of scroll)
- const scrollRange = [40, headerRef.current?.offsetHeight ?? 128]
-
- // Output range: The Y position range for the icon (e.g., 0 to 150px movement)
- const iconYRange = [0, headerRef.current?.offsetHeight ? headerRef.current.offsetHeight / 2 : 64] // Change 150 to set the end Y position of the icon
- // Output range: The size range for the icon container
- const sizeRange = [32, 20] // From 24px (scrolled) to 32px (top)
- // Output range: The padding range for the image
- const iconPaddingRange = [3, 1.5] // From 1.5px (scrolled) to 4px (top)
-
- // Map scrollY to the icon's Y position
- const iconY = useTransform(scroll?.scrollY!, scrollRange, iconYRange)
-
- const iconSize = useTransform(scroll?.scrollY!, scrollRange, sizeRange)
-
- const iconPadding = useTransform(scroll?.scrollY!, scrollRange, iconPaddingRange)
-
- if (!router?.isReady) {
- return null
- }
-
- return (
- <>
-
-
- {/* Main header content */}
-
-
- {/* Container with animated padding */}
-
- {/* Navigation link back to integrations landing */}
-
- {/* Back arrow */}
-
- {!isIntegrationsHome && (
-
-
-
-
-
- )}
-
- {/* Two separate spans with the same key */}
- {isIntegrationsHome ? (
-
- Integrations
-
- ) : (
-
- Integrations
-
- )}
-
-
- {/* Integration details section - only shown when viewing a specific integration */}
-
- {!isIntegrationsHome && integration && (
-
- {/* Integration icon */}
-
- {integration.icon({
- style: {
- padding: iconPadding.get(),
- },
- })}
-
-
- {/* Integration name and description */}
-
-
-
- {integration.name}
- {integration.status && (
-
- {integration.status}
-
- )}
-
-
{integration.description}
-
-
-
- )}
-
-
-
-
-
- >
- )
-})
-
-Header.displayName = 'Header'
diff --git a/apps/studio/components/layouts/Integrations/layout.tsx b/apps/studio/components/layouts/Integrations/layout.tsx
index 8a0bd18923cc4..bef2d1ce074e6 100644
--- a/apps/studio/components/layouts/Integrations/layout.tsx
+++ b/apps/studio/components/layouts/Integrations/layout.tsx
@@ -1,86 +1,34 @@
import { useRouter } from 'next/router'
-import { PropsWithChildren, useEffect, useRef, useState } from 'react'
+import { PropsWithChildren } from 'react'
import { useFlag } from 'common'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
-import { Header } from 'components/layouts/Integrations/header'
import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout'
import AlertError from 'components/ui/AlertError'
import { ProductMenu } from 'components/ui/ProductMenu'
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
-import { useScroll } from 'framer-motion'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { Menu, Separator } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
-import { IntegrationTabs } from './tabs'
/**
* Layout component for the Integrations section
- * Handles scroll-based sticky header behavior and authentication
+ * Provides sidebar navigation for integrations
*/
-const IntegrationsLayout = ({ ...props }: PropsWithChildren) => {
- const layoutSidebar = useFlag('integrationLayoutSidebar')
- if (layoutSidebar) {
- return
- }
- return
-}
-
-/**
- * Top level layout
- */
-const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => {
- const { data: project } = useSelectedProjectQuery()
+const IntegrationsLayout = ({ children }: PropsWithChildren) => {
const router = useRouter()
- // Refs for the main scrollable area and header
- const mainElementRef = useRef(null)
- const headerRef = useRef(null)
- // Track if header should be in sticky state
- const [isSticky, setIsSticky] = useState(false)
-
- // State to hold the scrollable container element
- const [container, setContainer] = useState(null)
-
- // Initialize framer-motion scroll tracking
- // Only tracks scroll when container is available
- const scroll = useScroll({
- container: container ? { current: container } : undefined,
- })
-
- // Set up container reference once mainElementRef is mounted
- useEffect(() => {
- if (mainElementRef.current) {
- setContainer(mainElementRef.current)
- }
- }, [mainElementRef.current])
-
- // Set up scroll event listener to handle sticky header behavior
- useEffect(() => {
- // Exit if scroll tracking isn't available yet
- if (!scroll.scrollY) return
-
- // Update sticky state based on scroll position relative to header height
- const handleScroll = (latest: number) => {
- if (headerRef.current) {
- setIsSticky(latest > headerRef.current.offsetHeight)
- }
- }
-
- // Subscribe to scroll position changes
- const unsubscribe = scroll.scrollY.on('change', handleScroll)
-
- // Clean up scroll listener on unmount
- return () => {
- unsubscribe()
- }
- }, [scroll.scrollY])
+ const { data: project } = useSelectedProjectQuery()
const segments = router.asPath.split('/')
// construct the page url to be used to determine the active state for the sidebar
const page = `${segments[3]}${segments[4] ? `/${segments[4]}` : ''}`
+ // Check for category query parameter to determine active menu item
+ const urlParams = new URLSearchParams(router.asPath.split('?')[1] || '')
+ const categoryParam = urlParams.get('category')
+
const {
installedIntegrations: integrations,
error,
@@ -88,6 +36,7 @@ const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => {
isSuccess,
isError,
} = useInstalledIntegrations()
+
const installedIntegrationItems = integrations.map((integration) => ({
name: integration.name,
label: integration.status,
@@ -103,19 +52,27 @@ const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => {
return (
-
+
- Installed integrations
+ Installed
}
/>
@@ -145,81 +102,7 @@ const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => {
>
}
>
-
-
- {props.children}
-
- )
-}
-
-const IntegrationsLayoutSide = ({ ...props }: PropsWithChildren) => {
- const router = useRouter()
- const page = router.pathname.split('/')[4]
- const { data: project } = useSelectedProjectQuery()
-
- const {
- installedIntegrations: integrations,
- error,
- isLoading,
- isError,
- isSuccess,
- } = useInstalledIntegrations()
- const installedIntegrationItems = integrations.map((integration) => ({
- name: integration.name,
- label: integration.status,
- key: `integrations/${integration.id}`,
- url: `/project/${project?.ref}/integrations/${integration.id}/overview`,
- icon: (
-
- {integration.icon({ className: 'p-1' })}
-
- ),
- items: [],
- }))
-
- return (
-
-
-
-
-
- Installed integrations
-
- }
- />
- {isLoading && }
- {isError && (
-
- )}
- {isSuccess && (
-
- {installedIntegrationItems.map((item) => (
-
- ))}
-
- )}
-
- >
- }
- >
- {props.children}
+ {children}
)
}
@@ -230,12 +113,27 @@ export default withAuth(IntegrationsLayout)
const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): ProductMenuGroup[] => {
return [
{
- title: 'All Integrations',
+ title: 'Explore',
items: [
{
- name: 'All Integrations',
+ name: 'All',
key: 'integrations',
url: `/project/${projectRef}/integrations`,
+ pages: ['integrations'],
+ items: [],
+ },
+ {
+ name: 'Wrappers',
+ key: 'integrations-wrapper',
+ url: `/project/${projectRef}/integrations?category=wrapper`,
+ pages: ['integrations?category=wrapper'],
+ items: [],
+ },
+ {
+ name: 'Postgres Modules',
+ key: 'integrations-postgres_extension',
+ url: `/project/${projectRef}/integrations?category=postgres_extension`,
+ pages: ['integrations?category=postgres_extension'],
items: [],
},
],
diff --git a/apps/studio/components/layouts/Integrations/tabs.tsx b/apps/studio/components/layouts/Integrations/tabs.tsx
deleted file mode 100644
index 2bb36708ff4cc..0000000000000
--- a/apps/studio/components/layouts/Integrations/tabs.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import { AnimatePresence, motion, MotionProps, useScroll, useTransform } from 'framer-motion'
-import { ChevronRight } from 'lucide-react'
-import Link from 'next/link'
-import { ComponentProps, ComponentType, useRef } from 'react'
-
-import { useBreakpoint, useParams } from 'common'
-import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
-import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
-import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { cn, NavMenu, NavMenuItem } from 'ui'
-
-const MotionNavMenu = motion(NavMenu) as ComponentType & MotionProps>
-
-// Output range: The padding range for the nav (from compact to expanded)
-const paddingRange = [40, 86]
-
-// Output range: The padding range for the image
-const iconPaddingRange = [3, 1.5] // From 1.5px (scrolled) to 4px (top)
-
-interface IntegrationTabsProps {
- scroll: ReturnType
- isSticky?: boolean
-}
-
-export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => {
- const navRef = useRef(null)
- const { data: project } = useSelectedProjectQuery()
- const { id, pageId, childId, childLabel } = useParams()
- const isMobile = useBreakpoint('md')
-
- const { installedIntegrations } = useInstalledIntegrations()
- // Find the integration details based on ID
- const integration = INTEGRATIONS.find((i) => i.id === id)
-
- const headerRef = useRef(null)
-
- // Input range: The scrollY range for triggering the animation (e.g., 0 to 200px of scroll)
- const scrollRange = [40, headerRef.current?.offsetHeight ?? 128]
- const navInnerLeftPaddingX = useTransform(scroll?.scrollY!, scrollRange, paddingRange)
- const iconPadding = useTransform(scroll?.scrollY!, scrollRange, iconPaddingRange)
-
- const installedIntegration = installedIntegrations?.find((i) => i.id === id)
-
- const tabs = installedIntegration
- ? integration?.navigation ?? []
- : (integration?.navigation ?? []).filter((tab) => tab.route === 'overview')
-
- if (!integration) return null
-
- return (
-
-
-
- {isSticky && (
-
- {integration?.icon({
- style: { padding: iconPadding.get() },
- })}
-
- )}
-
- {tabs.map((tab) => {
- const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}`
- return (
-
-
- {tab.label}
-
-
-
- {tab.hasChild && childId && (
- <>
-
-
-
-
-
- {tab.childIcon}
-
- {childLabel ? childLabel : childId}
-
-
-
- >
- )}
-
-
- )
- })}
-
-
-
- )
-}
-
-IntegrationTabs.displayName = 'IntegrationTabs'
diff --git a/apps/studio/components/layouts/PageLayout/PageLayout.tsx b/apps/studio/components/layouts/PageLayout/PageLayout.tsx
index f725e915cb333..e9144545a7ec4 100644
--- a/apps/studio/components/layouts/PageLayout/PageLayout.tsx
+++ b/apps/studio/components/layouts/PageLayout/PageLayout.tsx
@@ -20,7 +20,7 @@ export interface NavigationItem {
interface PageLayoutProps {
children?: ReactNode
title?: string | ReactNode
- subtitle?: string
+ subtitle?: string | ReactNode
icon?: ReactNode
breadcrumbs?: Array<{
label?: string
@@ -81,7 +81,7 @@ export const PageLayout = ({
className={cn(
'w-full mx-auto',
size === 'full' &&
- (isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 border-b'),
+ (isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 !px-10 border-b'),
size !== 'full' && (isCompact ? 'pt-4' : 'pt-12'),
navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8'),
className
diff --git a/apps/studio/components/layouts/Scaffold.tsx b/apps/studio/components/layouts/Scaffold.tsx
index f98d986389a1b..0d9851b26182c 100644
--- a/apps/studio/components/layouts/Scaffold.tsx
+++ b/apps/studio/components/layouts/Scaffold.tsx
@@ -2,7 +2,7 @@ import { forwardRef, HTMLAttributes } from 'react'
import { cn } from 'ui'
export const MAX_WIDTH_CLASSES = 'mx-auto w-full max-w-[1200px]'
-export const PADDING_CLASSES = 'px-4 @lg:px-6 @xl:px-12 @2xl:px-20 @3xl:px-24'
+export const PADDING_CLASSES = 'px-4 @lg:px-6 @xl:px-10'
export const MAX_WIDTH_CLASSES_COLUMN = 'min-w-[420px]'
/**
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 2e7b2396856c4..ab918eb91d275 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
@@ -10,6 +10,7 @@ export type DatabaseCronJobCreateVariables = {
connectionString?: string | null
query: string
searchTerm?: string
+ identifier?: string | number
}
export async function createDatabaseCronJob({
@@ -43,10 +44,15 @@ export const useDatabaseCronJobCreateMutation = ({
(vars) => createDatabaseCronJob(vars),
{
async onSuccess(data, variables, context) {
- const { projectRef, searchTerm } = variables
- await queryClient.invalidateQueries(
- databaseCronJobsKeys.listInfinite(projectRef, searchTerm)
- )
+ const { projectRef, searchTerm, identifier } = variables
+
+ await Promise.all([
+ queryClient.invalidateQueries(databaseCronJobsKeys.listInfinite(projectRef, searchTerm)),
+ ...(!!identifier
+ ? [queryClient.invalidateQueries(databaseCronJobsKeys.job(projectRef, identifier))]
+ : []),
+ ])
+
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx
index 91321e9afde90..e2527a9219d4e 100644
--- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx
+++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx
@@ -3,10 +3,13 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import DefaultLayout from 'components/layouts/DefaultLayout'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useRouter } from 'next/compat/router'
import { useEffect, useMemo } from 'react'
import { NextPageWithLayout } from 'types'
+import { Admonition } from 'ui-patterns'
const IntegrationPage: NextPageWithLayout = () => {
const router = useRouter()
@@ -40,23 +43,40 @@ const IntegrationPage: NextPageWithLayout = () => {
) {
router.replace(`/project/${ref}/integrations/${id}/overview`)
}
- }, [installation, isIntegrationsLoading, pageId, router])
+ }, [installation, isIntegrationsLoading, pageId, router, ref, id])
- if (!router?.isReady || isIntegrationsLoading) {
- return (
-
-
-
- )
- }
-
- if (!id || !integration) {
- return Integration not found
- }
-
- if (!Component) return Component not found
+ // Determine content based on state
+ const content = useMemo(() => {
+ if (!router?.isReady || isIntegrationsLoading) {
+ return (
+
+
+
+
+
+ )
+ } else if (!Component || !id || !integration) {
+ return (
+
+
+
+
+ Please try again later or contact support if the problem persists.
+
+
+
+
+ )
+ } else {
+ return
+ }
+ }, [router?.isReady, isIntegrationsLoading, id, integration, Component])
- return
+ return content
}
IntegrationPage.getLayout = (page) => (
diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
index 91321e9afde90..b0eb70e5efe46 100644
--- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
+++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
@@ -3,10 +3,13 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import DefaultLayout from 'components/layouts/DefaultLayout'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
+import { PageLayout, NavigationItem } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useRouter } from 'next/compat/router'
import { useEffect, useMemo } from 'react'
import { NextPageWithLayout } from 'types'
+import { Admonition } from 'ui-patterns'
const IntegrationPage: NextPageWithLayout = () => {
const router = useRouter()
@@ -29,6 +32,36 @@ const IntegrationPage: NextPageWithLayout = () => {
[integration, id, pageId, childId]
)
+ // Create breadcrumb items
+ const breadcrumbItems = [
+ {
+ label: 'Integrations',
+ href: `/project/${ref}/integrations`,
+ },
+ {
+ label: integration?.name || 'Integration not found',
+ },
+ ]
+
+ // Create navigation items from integration navigation
+ const navigationItems: NavigationItem[] = useMemo(() => {
+ if (!integration?.navigation) return []
+
+ // Only show navigation if the integration is installed, or if we're on the overview page
+ const showNavigation = installation || pageId === 'overview'
+ if (!showNavigation) return []
+
+ const availableTabs = installation
+ ? integration.navigation
+ : integration.navigation.filter((tab) => tab.route === 'overview')
+
+ return availableTabs.map((nav) => ({
+ label: nav.label,
+ href: `/project/${ref}/integrations/${id}/${nav.route}`,
+ active: pageId === nav.route,
+ }))
+ }, [integration, ref, id, pageId, installation])
+
useEffect(() => {
// if the integration is not installed, redirect to the overview page
if (
@@ -42,21 +75,60 @@ const IntegrationPage: NextPageWithLayout = () => {
}
}, [installation, isIntegrationsLoading, pageId, router])
- if (!router?.isReady || isIntegrationsLoading) {
- return (
-
-
-
- )
- }
+ // Determine page title, icon, and subtitle based on state
+ const pageTitle = integration?.name || 'Integration not found'
- if (!id || !integration) {
- return Integration not found
- }
+ const pageSubTitle =
+ integration?.description || 'If you think this is an error, please contact support'
+
+ // Get integration icon and subtitle
+ const pageIcon = integration ? (
+
+ {integration.icon()}
+
+ ) : null
+
+ // Determine content based on state
+ const content = useMemo(() => {
+ if (!router?.isReady || isIntegrationsLoading) {
+ return (
+
+
+
+
+
+ )
+ } else if (!Component || !id || !integration) {
+ return (
+
+
+
+ Please try again later or contact support if the problem persists.
+
+
+
+ )
+ } else {
+ return
+ }
+ }, [router?.isReady, isIntegrationsLoading, id, integration, Component])
- if (!Component) return Component not found
+ if (!router?.isReady) {
+ return null
+ }
- return
+ return (
+
+ {content}
+
+ )
}
IntegrationPage.getLayout = (page) => (
diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx
index 91321e9afde90..37e5f844e0da5 100644
--- a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx
+++ b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx
@@ -1,62 +1,31 @@
import { useParams } from 'common'
-import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
-import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import DefaultLayout from 'components/layouts/DefaultLayout'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useRouter } from 'next/compat/router'
-import { useEffect, useMemo } from 'react'
+import { useEffect } from 'react'
import { NextPageWithLayout } from 'types'
const IntegrationPage: NextPageWithLayout = () => {
const router = useRouter()
- const { ref, id, pageId, childId } = useParams()
-
- const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } =
- useInstalledIntegrations()
-
- // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net
- const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id])
-
- const installation = useMemo(
- () => installedIntegrations.find((inst) => inst.id === id),
- [installedIntegrations, id]
- )
-
- // Get the corresponding component dynamically
- const Component = useMemo(
- () => integration?.navigate(id!, pageId, childId),
- [integration, id, pageId, childId]
- )
+ const { ref, id } = useParams()
useEffect(() => {
- // if the integration is not installed, redirect to the overview page
- if (
- router &&
- router?.isReady &&
- !isIntegrationsLoading &&
- !installation &&
- pageId !== 'overview'
- ) {
+ // Always redirect to the overview page since this route should not render content
+ if (router?.isReady) {
router.replace(`/project/${ref}/integrations/${id}/overview`)
}
- }, [installation, isIntegrationsLoading, pageId, router])
+ }, [router, ref, id])
- if (!router?.isReady || isIntegrationsLoading) {
- return (
-
+ return (
+
+
-
- )
- }
-
- if (!id || !integration) {
- return Integration not found
- }
-
- if (!Component) return Component not found
-
- return
+
+
+ )
}
IntegrationPage.getLayout = (page) => (
diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx
index 45364188a61e4..dcb1a512faaa2 100644
--- a/apps/studio/pages/project/[ref]/integrations/index.tsx
+++ b/apps/studio/pages/project/[ref]/integrations/index.tsx
@@ -1,16 +1,216 @@
-import { AvailableIntegrations } from 'components/interfaces/Integrations/Landing/AvailableIntegrations'
-import { InstalledIntegrations } from 'components/interfaces/Integrations/Landing/InstalledIntegrations'
+import { Search } from 'lucide-react'
+import { parseAsString, useQueryState } from 'nuqs'
+import { useMemo } from 'react'
+
+import {
+ IntegrationCard,
+ IntegrationLoadingCard,
+} from 'components/interfaces/Integrations/Landing/IntegrationCard'
+import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import DefaultLayout from 'components/layouts/DefaultLayout'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import AlertError from 'components/ui/AlertError'
+import { DocsButton } from 'components/ui/DocsButton'
+import NoSearchResults from 'components/ui/NoSearchResults'
import type { NextPageWithLayout } from 'types'
+import { Input } from 'ui-patterns/DataInputs/Input'
+
+const FEATURED_INTEGRATIONS = ['cron', 'queues', 'stripe_wrapper']
+
+// Featured integration images
+const FEATURED_INTEGRATION_IMAGES: Record = {
+ cron: 'img/integrations/covers/cron-cover.webp',
+ queues: 'img/integrations/covers/queues-cover.png',
+ stripe_wrapper: 'img/integrations/covers/stripe-cover.png',
+}
const IntegrationsPage: NextPageWithLayout = () => {
- return (
-
-
-
+ const [selectedCategory] = useQueryState(
+ 'category',
+ parseAsString.withDefault('all').withOptions({ clearOnDefault: true })
+ )
+ const [search, setSearch] = useQueryState(
+ 'search',
+ parseAsString.withDefault('').withOptions({ clearOnDefault: true })
+ )
+
+ const { availableIntegrations, installedIntegrations, error, isError, isLoading, isSuccess } =
+ useInstalledIntegrations()
+
+ const installedIds = installedIntegrations.map((i) => i.id)
+
+ // Dynamic page content based on selected category
+ const pageContent = useMemo(() => {
+ switch (selectedCategory) {
+ case 'wrapper':
+ return {
+ title: 'Wrappers',
+ subtitle:
+ 'Connect to external data sources and services by querying APIs, databases, and files as if they were Postgres tables.',
+ secondaryActions: (
+
+ ),
+ }
+ case 'postgres_extension':
+ return {
+ title: 'Postgres Modules',
+ subtitle: 'Extend your database with powerful Postgres extensions.',
+ }
+ default:
+ return {
+ title: 'Extend your database',
+ subtitle:
+ 'Extensions and wrappers that add functionality to your database and connect to external services.',
+ }
+ }
+ }, [selectedCategory])
+
+ const filteredAndSortedIntegrations = useMemo(() => {
+ let filtered = availableIntegrations
+
+ if (selectedCategory !== 'all') {
+ filtered = filtered.filter((i) => i.type === selectedCategory)
+ }
+
+ if (search.length > 0) {
+ filtered = filtered.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
+ }
+
+ // Sort by installation status, then alphabetically
+ return filtered.sort((a, b) => {
+ const aIsInstalled = installedIds.includes(a.id)
+ const bIsInstalled = installedIds.includes(b.id)
+
+ if (aIsInstalled && !bIsInstalled) return -1
+ if (!aIsInstalled && bIsInstalled) return 1
+
+ return a.name.localeCompare(b.name)
+ })
+ }, [availableIntegrations, selectedCategory, search, installedIds])
+
+ const groupedIntegrations = useMemo(() => {
+ if (selectedCategory !== 'all' || search.length > 0) {
+ return null
+ }
+
+ const featured = filteredAndSortedIntegrations.filter((i) =>
+ FEATURED_INTEGRATIONS.includes(i.id)
+ )
+ const allIntegrations = filteredAndSortedIntegrations // Include all integrations, including featured
+
+ return {
+ featured,
+ allIntegrations,
+ }
+ }, [filteredAndSortedIntegrations, selectedCategory, search])
+
+ // Helper component to render featured integrations grid
+ const FeaturedIntegrationsGrid = ({
+ integrations,
+ }: {
+ integrations: typeof filteredAndSortedIntegrations
+ }) => (
+
+ {integrations.map((integration) => (
+
+ ))}
)
+
+ // Helper component to render all integrations grid
+ const AllIntegrationsGrid = ({
+ integrations,
+ }: {
+ integrations: typeof filteredAndSortedIntegrations
+ }) => (
+
+ {integrations.map((integration) => (
+
+ ))}
+
+ )
+
+ return (
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search integrations..."
+ icon={}
+ className="w-52"
+ />
+
+
+ {isLoading && (
+
+ {new Array(8).fill(0).map((_, idx) => (
+
+ ))}
+
+ )}
+
+ {/* Error State */}
+ {isError && (
+
+ )}
+
+ {/* Success State */}
+ {isSuccess && (
+ <>
+ {/* No Search Results */}
+ {search.length > 0 && filteredAndSortedIntegrations.length === 0 && (
+ setSearch('')} />
+ )}
+
+ {/* Grouped View (All integrations, no search) */}
+ {groupedIntegrations && (
+ <>
+ {/* Featured Integrations */}
+ {groupedIntegrations.featured.length > 0 && (
+
+ )}
+
+ {/* All Integrations */}
+ {groupedIntegrations.allIntegrations.length > 0 && (
+
+ )}
+ >
+ )}
+
+ {/* Single List View (Category filtered or searching) */}
+ {!groupedIntegrations && filteredAndSortedIntegrations.length > 0 && (
+
+ )}
+ >
+ )}
+
+
+
+ )
}
IntegrationsPage.getLayout = (page) => (
diff --git a/apps/studio/public/img/integrations/covers/cron-cover.webp b/apps/studio/public/img/integrations/covers/cron-cover.webp
new file mode 100644
index 0000000000000..54b4f0793a2fd
Binary files /dev/null and b/apps/studio/public/img/integrations/covers/cron-cover.webp differ
diff --git a/apps/studio/public/img/integrations/covers/queues-cover.png b/apps/studio/public/img/integrations/covers/queues-cover.png
new file mode 100644
index 0000000000000..cc3f2254abc2a
Binary files /dev/null and b/apps/studio/public/img/integrations/covers/queues-cover.png differ
diff --git a/apps/studio/public/img/integrations/covers/stripe-cover.png b/apps/studio/public/img/integrations/covers/stripe-cover.png
new file mode 100644
index 0000000000000..2f15fa367afa9
Binary files /dev/null and b/apps/studio/public/img/integrations/covers/stripe-cover.png differ