fix(studio): remove marketplaceIntegrations flag, make new integrations layout permanent

The new marketplace-style integrations layout (gated behind the
`marketplaceIntegrations` ConfigCat flag and the "One-Click Integrations"
feature-preview opt-in) becomes the only integrations experience, since
some integrations don't work correctly in the old layout. Removes the
flag, the feature-preview entry, and every parallel "legacy" code path
that only rendered when the flag was off, along with now-dead
components, utils, and tests.
This commit is contained in:
Claude committed 2026-07-23 15:21:36 +00:00
1 parent 7781ee0d04
commit e796c083e4
38 files changed
+80 -1890

No files matched your search

-1
View File
@@ -639,7 +639,6 @@
"components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx": 1,
"components/interfaces/Integrations/CronJobs/CronJobsEmptyState.tsx": 1,
"components/interfaces/Integrations/CronJobs/CronSyntaxChart.tsx": 1,
"components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx": 1,
"components/interfaces/Integrations/Vercel/OrganizationPicker.tsx": 1,
"components/interfaces/Integrations/Wrappers/InputField.tsx": 1,
"components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx": 1,
+1 -1
View File
@@ -96,7 +96,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
- [x] `routes/project/$ref/advisors.tsx` — AdvisorsLayout (reads `advisorsLayoutTitle` from leaf staticData). Honours `skipAdvisorsLayout: true` opt-out for the rules sub-shell, which provides its own AdvisorsLayout-less-DefaultLayout wrap. Scans whole match chain (same pattern as functions.tsx).
- [x] `routes/project/$ref/advisors/rules.tsx` — sub-shell that inlines the inner body of `AdvisorRulesLayout` (AdvisorsLayout + PageLayout with title/tabs/feature-preview badge), minus the outer DefaultLayout (already provided by the parent project shell). Sets `skipAdvisorsLayout: true` on its own staticData. **Delta vs plan:** the existing `AdvisorRulesLayout` component wraps in DefaultLayout + AdvisorsLayout internally, so reusing it as-is would double-wrap both. Inlined the inner part; the Next-side component is untouched.
- [x] `routes/project/$ref/settings.tsx` — SettingsLayout (reads `settingsLayoutTitle` from leaf staticData). Honours `skipSettingsLayout: true` for `settings/api` (redirect-only page). Adds a sub-shell at `routes/project/$ref/settings/api-keys.tsx` providing `ApiKeysLayout` for both api-keys leaves; `jwt/index` wraps in `JWTKeysLayout` inline since `jwt/legacy` doesn't share it.
- [x] `routes/project/$ref/integrations.tsx` — ProjectIntegrationsLayout (no staticData; all 4 leaves share identical layout). Layout is `withAuth(({ children }) => <ProjectLayout>{children}</ProjectLayout>)`, so the shell just wraps `<Outlet />` once.
- [x] `routes/project/$ref/integrations.tsx` — ProjectMarketplaceLayout (no staticData; all 4 leaves share identical layout). Layout is `withAuth(({ children }) => <ProjectLayout>{children}</ProjectLayout>)`, so the shell just wraps `<Outlet />` once.
- [x] `routes/project/$ref/sql.tsx` — EditorBaseLayout + SQLEditorLayout. Twin of editor.tsx; all four leaves share identical layout props so the shell hardcodes them (no `staticData` overrides). EditorBaseLayout wraps in ProjectLayoutWithAuth; SQLEditorLayout adds its own `withAuth` HOC but no extra ProjectLayout — same shape as the table editor (auth check runs twice but no double render).
- [x] `routes/project/$ref/editor.tsx` — EditorBaseLayout + TableEditorLayout. All three leaves share identical layout props so the shell hardcodes them (no `staticData` overrides). EditorBaseLayout wraps in `ProjectLayoutWithAuth` internally; TableEditorLayout's happy path is just a fragment + side-effect (banner) and only wraps in `ProjectLayoutWithAuth` on its no-permission branch — same as Next, no double-wrap in normal use.
@@ -134,12 +134,6 @@ export const useIsSqlEditorManualSaveEnabled = () => {
return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
}
export const useIsMarketplaceEnabled = () => {
const { flags } = useFeaturePreviewContext()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
return isMarketplaceEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]
}
export const useFeaturePreviewModal = () => {
const featurePreviews = useFeaturePreviews()
const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal')
@@ -30,7 +30,6 @@ import {
import { AdvisorRulesPreview } from './AdvisorRulesPreview'
import { CLSPreview } from './CLSPreview'
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
import { IntegrationsLayoutPreview } from './IntegrationsLayoutPreview'
import { JitDbAccessPreview } from './JitDbAccessPreview'
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
@@ -50,7 +49,6 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: <JitDbAccessPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: <SqlEditorManualSavePreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: <IntegrationsLayoutPreview />,
}
export const FeaturePreviewModal = () => {
@@ -1,18 +0,0 @@
import Image from 'next/image'
import { BASE_PATH } from 'ui-patterns/CommandMenu/prepackaged/shared/constants'
export const IntegrationsLayoutPreview = () => (
<div>
<p className="text-sm text-foreground-light mb-4">
Install Dashboard Integrations in a single click and try the new layout.
</p>
<Image
alt="integrations layout preview"
src={`${BASE_PATH}/img/previews/integrations-layout-preview.png`}
width={1296}
height={900}
className="rounded-sm border"
/>
</div>
)
@@ -22,7 +22,6 @@ export type FeaturePreview = {
export const useFeaturePreviews = (): FeaturePreview[] => {
const platformWebhooksEnabled = useFlag('platformWebhooks')
const jitDbAccessEnabled = useFlag('jitDbAccess')
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn')
@@ -90,16 +89,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isDefaultOptIn: false,
getRoute: (ref?: string) => `/project/${ref}/database/column-privileges`,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
name: 'One-Click Integrations',
discussionsUrl: undefined,
enabled: isMarketplaceEnabled,
isNew: true,
isPlatformOnly: false,
isDefaultOptIn: true,
getRoute: (ref?: string) => `/project/${ref}/integrations`,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
name: 'Disable snippet auto-saving',
@@ -110,12 +99,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
enabled: sqlEditorManualSaveEnabled,
},
].sort((a, b) => Number(b.isNew) - Number(a.isNew)),
[
unifiedLogsDefaultOptIn,
platformWebhooksEnabled,
jitDbAccessEnabled,
isMarketplaceEnabled,
sqlEditorManualSaveEnabled,
]
[unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, sqlEditorManualSaveEnabled]
)
}
@@ -1,8 +1,6 @@
import { PropsWithChildren } from 'react'
import { cn } from 'ui'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface ConstrainedIntegrationTabScaffoldProps extends PropsWithChildren {
className?: string
}
@@ -10,11 +8,6 @@ interface ConstrainedIntegrationTabScaffoldProps extends PropsWithChildren {
export const ConstrainedIntegrationTabScaffold = ({
children,
className,
}: ConstrainedIntegrationTabScaffoldProps) => {
const isMarketplace = useIsMarketplaceEnabled()
return (
<div className={cn('w-full py-6 xl:py-10', !isMarketplace && 'px-6 xl:px-10', className)}>
{children}
</div>
)
}
}: ConstrainedIntegrationTabScaffoldProps) => (
<div className={cn('w-full py-6 xl:py-10', className)}>{children}</div>
)
@@ -1,10 +1,3 @@
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const CronOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
return <IntegrationOverviewTab />
}
export const CronOverviewTab = () => <RequiredExtensionsSection />
@@ -1,17 +1,3 @@
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { DataApiURLSettings } from './DataApiURLSettings'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const DataApiOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <DataApiURLSettings />
return (
<IntegrationOverviewTab>
<div className="px-4 md:px-10 max-w-4xl space-y-4">
<DataApiURLSettings />
</div>
</IntegrationOverviewTab>
)
}
export const DataApiOverviewTab = () => <DataApiURLSettings />
@@ -1,10 +1,3 @@
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const GraphQLOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
return <IntegrationOverviewTab />
}
export const GraphQLOverviewTab = () => <RequiredExtensionsSection />
@@ -1,74 +0,0 @@
import { Book } from 'lucide-react'
import Link from 'next/link'
import { ComponentPropsWithoutRef, ElementRef, forwardRef, ReactNode } from 'react'
import { cn } from 'ui'
import { IntegrationDefinition } from '../Landing/Integrations.constants'
interface BuiltBySectionProps extends ComponentPropsWithoutRef<'div'> {
integration: IntegrationDefinition
status?: string | ReactNode
}
export const BuiltBySection = forwardRef<ElementRef<'div'>, BuiltBySectionProps>(
({ integration, status, className, ...props }, ref) => {
const { docsUrl } = integration
const { name, websiteUrl } = integration?.author ?? {}
if (!name && !docsUrl && !websiteUrl) return null
return (
<div
ref={ref}
className={cn('flex flex-wrap items-center gap-8 md:gap-10 px-4 md:px-10', className)}
{...props}
>
{status && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">STATUS</div>
<div>{status}</div>
</div>
)}
{name && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">BUILT BY</div>
<div className="text-foreground-light text-sm">{name}</div>
</div>
)}
{docsUrl && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">DOCS</div>
<Link
href={docsUrl}
target="_blank"
rel="noreferrer"
className="text-foreground-light hover:text-foreground text-sm flex items-center gap-2"
>
<Book size={16} />
{docsUrl.includes('supabase.com/docs')
? 'Supabase Docs'
: docsUrl.includes('github.com')
? 'GitHub Docs'
: 'Documentation'}
</Link>
</div>
)}
{websiteUrl && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">WEBSITE</div>
<Link
href={websiteUrl}
target="_blank"
rel="noreferrer"
className="text-foreground-light hover:text-foreground text-sm"
>
{websiteUrl.replace('https://', '')}
</Link>
</div>
)}
</div>
)
}
)
BuiltBySection.displayName = 'BuiltBySection'
@@ -8,9 +8,7 @@ const TAB_DIGIT_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as const
/**
* Binds digit keys 1-9 to whichever tabs the active integration declares in
* its `navigation` array. Render this once per integration detail page —
* shared between `LegacyIntegrationPage` and the marketplace `MarketplaceDetail`
* since both surfaces drive off the same `tabs` shape from `useIntegrationDetail`.
* its `navigation` array. Render this once per integration detail page.
*
* The dynamic shortcut registry handles per-tab label overrides ("Go to Jobs",
* "Go to Wrappers", etc.) so the reference sheet and Cmd+K pick up the actual
@@ -1,98 +0,0 @@
import { screen } from '@testing-library/dom'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IntegrationOverviewTab } from './IntegrationOverviewTab'
import { customRender } from '@/tests/lib/custom-render'
import { routerMock } from '@/tests/lib/route-mock'
mockAnimationsApi()
vi.mock('../Landing/Integrations.constants', () => ({
INTEGRATIONS: [
{
id: 'test-integration',
name: 'Test Integration',
requiredExtensions: ['pg_net'],
},
],
}))
vi.mock('framer-motion', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
motion: {
...actual.motion,
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
}
})
const mockExtensions = vi.fn()
vi.mock('@/data/database-extensions/database-extensions-query', () => ({
useDatabaseExtensionsQuery: () => ({ data: mockExtensions() }),
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useSelectedProjectQuery: () => ({
data: { ref: 'default', connectionString: 'postgres://localhost' },
}),
useIsOrioleDb: () => false,
}))
vi.mock('common', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
useParams: () => ({ id: 'test-integration', ref: 'default' }),
}
})
vi.mock('./MarkdownContent', () => ({
MarkdownContent: () => null,
}))
vi.mock('../Landing/useIntegrationDetail', () => ({
useIntegrationDetail: () => ({
integration: {
id: 'test-integration',
name: 'Test Integration',
requiredExtensions: ['pg_net'],
missingExtensionsAlert: null,
},
}),
}))
describe('IntegrationOverviewTab', () => {
beforeEach(() => {
routerMock.setCurrentUrl('/project/default/integrations/test-integration/overview')
mockExtensions.mockReturnValue([
{ name: 'pg_net', installed_version: null, default_version: '0.6.0' },
])
})
it('does not disable actions when hideRequiredExtensionsSection is true and extensions are uninstalled', () => {
customRender(
<IntegrationOverviewTab
hideRequiredExtensionsSection
actions={<button tabIndex={0}>Enable webhooks</button>}
/>
)
const actionsArea = screen.getByText('Enable webhooks').closest('[aria-disabled]')
expect(actionsArea).toHaveAttribute('aria-disabled', 'false')
expect(actionsArea).not.toHaveClass('opacity-25')
})
it('disables actions when extensions are uninstalled and hideRequiredExtensionsSection is false', () => {
customRender(
<IntegrationOverviewTab actions={<button tabIndex={0}>Enable integration</button>} />
)
const actionsArea = screen.getByText('Enable integration').closest('[aria-disabled]')
expect(actionsArea).toHaveAttribute('aria-disabled', 'true')
expect(actionsArea).toHaveClass('opacity-25')
})
})
@@ -1,103 +0,0 @@
import { useParams } from 'common'
import { PropsWithChildren, ReactNode } from 'react'
import { cn } from 'ui'
import { IntegrationDefinition, INTEGRATIONS } from '../Landing/Integrations.constants'
import { BuiltBySection } from './BuildBySection'
import { MarkdownContent } from './MarkdownContent'
import { RequiredExtensionsSection } from './RequiredExtensionsSection'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export interface IntegrationOverviewTabProps {
actions?: ReactNode
status?: string | ReactNode
alert?: ReactNode
hideRequiredExtensionsSection?: boolean
}
export const OverviewTabSharedContent = ({
integration,
hideRequiredExtensionsSection = false,
actions,
alert,
children,
}: PropsWithChildren<{
integration: IntegrationDefinition
hideRequiredExtensionsSection?: boolean
actions?: ReactNode
alert?: ReactNode
}>) => {
const { data: project } = useSelectedProjectQuery()
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (!integration) {
return <div>Unsupported integration type</div>
}
const installableExtensions = (extensions ?? []).filter((ext) =>
(integration.requiredExtensions ?? []).includes(ext.name)
)
const hasToInstallExtensions = installableExtensions.some((x) => !x.installed_version)
return (
<>
{!!alert && <div className="px-10 max-w-4xl">{alert}</div>}
<MarkdownContent key={integration.id} integrationId={integration.id} />
<RequiredExtensionsSection
hide={hideRequiredExtensionsSection}
className="px-4 md:px-10 max-w-4xl"
/>
{!!actions && (
<div
aria-disabled={hasToInstallExtensions && !hideRequiredExtensionsSection}
className={cn(
'px-10 max-w-4xl',
hasToInstallExtensions &&
!hideRequiredExtensionsSection &&
'opacity-25 [&_button]:pointer-events-none'
)}
>
{actions}
</div>
)}
{children}
</>
)
}
export const IntegrationOverviewTab = ({
actions,
alert,
status,
children,
hideRequiredExtensionsSection = false,
}: PropsWithChildren<IntegrationOverviewTabProps>) => {
const { id } = useParams()
const integration = INTEGRATIONS.find((i) => i.id === id)
if (!integration) {
return <div>Unsupported integration type</div>
}
return (
<div className="flex flex-col gap-8 py-10">
<BuiltBySection integration={integration} status={status} />
<OverviewTabSharedContent
integration={integration}
hideRequiredExtensionsSection={hideRequiredExtensionsSection}
alert={alert}
actions={actions}
>
{children}
</OverviewTabSharedContent>
</div>
)
}
@@ -1,148 +0,0 @@
import Link from 'next/link'
import { useMemo } from 'react'
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
NavMenu,
NavMenuItem,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderBreadcrumb,
PageHeaderDescription,
PageHeaderIcon,
PageHeaderMeta,
PageHeaderNavigationTabs,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { IntegrationLogo } from '@/components/interfaces/Integrations/Integration/IntegrationLogo'
import { InstallOAuthIntegrationButton } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton'
import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
const LegacyIntegrationPage = () => {
const {
ref,
id,
isReady,
isWrapperBlocked,
pageTitle,
pageSubTitle,
integration,
isAvailableLoading,
isInstalledLoading,
isIntegrationStatusLoading,
oauthIntegrationData,
tabs,
Component,
} = useIntegrationDetail()
const content = useMemo(() => {
if (!isReady || isInstalledLoading || isAvailableLoading) {
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
<GenericSkeletonLoader />
</PageSectionContent>
</PageSection>
</PageContainer>
)
} else if (!Component || !id || !integration) {
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
<Admonition type="warning" title="This integration is not currently available">
Please try again later or contact support if the problem persists.
</Admonition>
</PageSectionContent>
</PageSection>
</PageContainer>
)
} else {
return <Component />
}
}, [isReady, isInstalledLoading, isAvailableLoading, id, integration, Component])
if (!isReady) return null
if (isWrapperBlocked) return <UnknownInterface urlBack={`/project/${ref}/integrations`} />
return (
<>
<PageHeader size="full">
<PageHeaderBreadcrumb className="mx-auto w-full">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/project/${ref}/integrations`}>Integrations</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{integration?.name || 'Integration not found'}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageHeaderBreadcrumb>
{isAvailableLoading ? (
<PageHeaderMeta className="mx-auto w-full">
<PageHeaderSummary>
<PageHeaderTitle>
<ShimmeringLoader className="w-64 py-4" />
</PageHeaderTitle>
<PageHeaderDescription>
<ShimmeringLoader />
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
) : (
<PageHeaderMeta className="mx-auto w-full">
{integration && (
<PageHeaderIcon>
<IntegrationLogo integration={integration} size="w-14 h-14" />
</PageHeaderIcon>
)}
<PageHeaderSummary className="gap-y-0.5">
<PageHeaderTitle>{pageTitle}</PageHeaderTitle>
<PageHeaderDescription>{pageSubTitle}</PageHeaderDescription>
</PageHeaderSummary>
{integration?.type === 'oauth' && (
<InstallOAuthIntegrationButton
integration={integration}
data={oauthIntegrationData}
isLoading={isIntegrationStatusLoading}
/>
)}
</PageHeaderMeta>
)}
{tabs.length > 0 && (
<PageHeaderNavigationTabs className="mx-auto w-full">
<NavMenu>
{tabs.map((tab) => (
<NavMenuItem key={tab.href} active={tab.active}>
<Link href={tab.href}>{tab.label}</Link>
</NavMenuItem>
))}
</NavMenu>
</PageHeaderNavigationTabs>
)}
</PageHeader>
<div className="flex-1 min-h-0 mx-auto w-full">{content}</div>
</>
)
}
export default LegacyIntegrationPage
@@ -1,11 +0,0 @@
import type { PropsWithChildren } from 'react'
import { IntegrationOverviewTab } from './IntegrationOverviewTab'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const MarketplaceIntegrationOverviewTab = (props: PropsWithChildren) => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return null
return <IntegrationOverviewTab>{props.children}</IntegrationOverviewTab>
}
@@ -1,31 +0,0 @@
import { useState } from 'react'
import { Button } from 'ui'
import { EnableExtensionModal } from '@/components/interfaces/Database/Extensions/EnableExtensionModal'
import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query'
export const MissingExtensionAlert = ({ extension }: { extension: DatabaseExtension }) => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const extensionInstalled = !!extension?.installed_version
if (!extensionInstalled) {
return (
<>
<Button
variant="primary"
className="w-min"
onClick={() => setShowEnableExtensionModal(true)}
>
Enable {extension.name}
</Button>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={extension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
return null
}
@@ -1,7 +1,5 @@
import { Badge, Card, CardContent, cn, Separator } from 'ui'
import { useIsMarketplaceEnabled } from '../../App/FeaturePreview/FeaturePreviewContext'
import { MissingExtensionAlert } from './MissingExtensionAlert'
import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -21,7 +19,6 @@ export const RequiredExtensionsSection = ({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const requiredExtensions = integration?.requiredExtensions ?? []
@@ -59,10 +56,8 @@ export const RequiredExtensionsSection = ({
{extension ? (
isInstalled ? (
<Badge variant="secondary">Installed</Badge>
) : isMarketplaceEnabled ? (
<Badge variant="warning">Required</Badge>
) : (
<MissingExtensionAlert extension={extension} />
<Badge variant="warning">Required</Badge>
)
) : (
<span className="text-foreground-muted">Unavailable</span>
@@ -1,18 +1,6 @@
import { BadgeCheck } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { Badge, Card, CardContent, cn } from 'ui'
import { cn } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { IntegrationDefinition } from './Integrations.constants'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
type IntegrationCardProps = IntegrationDefinition & {
isInstalled?: boolean
featured?: boolean
image?: string
}
const INTEGRATION_CARD_STYLE = cn(
'w-full h-full bg-surface-100 hover:bg-surface-200 hover:border-strong',
'border border-border rounded-md ease-out duration-200 transition-all'
@@ -33,81 +21,3 @@ export const IntegrationLoadingCard = () => {
</div>
)
}
export const IntegrationCard = ({
id,
listingId,
status,
name,
icon,
description,
isInstalled,
featured = false,
image,
}: IntegrationCardProps) => {
const { data: project } = useSelectedProjectQuery()
const shouldShowOfficialBadge = !listingId
if (featured) {
return (
<Link href={`/project/${project?.ref}/integrations/${id}/overview`} className="h-full">
<Card className="h-full">
{/* Full-width image/icon at the top */}
<div className="w-full h-24 bg-surface-400 rounded-t-md flex items-center justify-center overflow-hidden relative">
{image ? (
<Image
fill
src={image}
alt={`${name} integration`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-12 h-12 text-foreground relative">
{icon({ className: 'w-full h-full text-foreground' })}
</div>
)}
</div>
<CardContent className="p-6 px-4">
<div className="flex-col justify-start items-center text-center gap-y-0.5 flex">
<h3>{name}</h3>
<p className="text-foreground-light text-sm line-clamp-3">{description}</p>
<div className="flex items-center gap-x-1 mt-4">
{status && <Badge variant="warning">{status}</Badge>}
{shouldShowOfficialBadge && <Badge>Official</Badge>}
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
return (
<Link href={`/project/${project?.ref}/integrations/${id}/overview`} className="h-full">
<Card className="h-full">
<CardContent className="flex flex-col p-4 @2xl:p-6 h-full">
<div className="flex items-start justify-between mb-4">
<div className="shrink-0 w-10 h-10 relative bg-white border rounded-md flex items-center justify-center">
{icon()}
</div>
{isInstalled && (
<div className="flex items-center gap-x-1">
<BadgeCheck size={14} className="text-brand-link" />
<span className="text-brand-link text-xs">Installed</span>
</div>
)}
</div>
<div className="flex-col justify-start items-start gap-y-0.5 flex flex-1">
<h3 className="text-foreground text-sm">{name}</h3>
<p className="text-foreground-light text-xs flex-1">{description}</p>
<div className="flex items-center gap-x-1 mt-4">
{status && <Badge variant="warning">{status}</Badge>}
{shouldShowOfficialBadge && <Badge>Official</Badge>}
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
@@ -13,7 +13,6 @@ import { useContext, useMemo } from 'react'
import { cn } from 'ui'
import { INTEGRATIONS, Loading, type IntegrationDefinition } from './Integrations.constants'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
useMarketplaceIntegrationsQuery,
type MarketplaceIntegration,
@@ -55,16 +54,11 @@ const isPreviewEnabled = (featureFlags: FeatureFlagContextType, listingSlug: str
const useMarketplaceListings = () => {
const { hasLoaded } = useContext(FeatureFlagContext)
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const { data: marketplaceData, error } = useMarketplaceIntegrationsQuery({
enabled: isMarketplaceEnabled,
})
const isPending =
IS_PLATFORM && (!hasLoaded || (isMarketplaceEnabled && !marketplaceData && !error))
const isSuccess =
!IS_PLATFORM || (hasLoaded && (!isMarketplaceEnabled || (!!marketplaceData && !error)))
const isError = IS_PLATFORM && isMarketplaceEnabled && !!error
const { data: marketplaceData, error } = useMarketplaceIntegrationsQuery()
const isPending = IS_PLATFORM && (!hasLoaded || (!marketplaceData && !error))
const isSuccess = !IS_PLATFORM || (hasLoaded && !!marketplaceData && !error)
const isError = IS_PLATFORM && !!error
// This flag can globally enable preview listings for all partners for a given user (i.e. for Supabase users)
const previewAllListingsEnabled = useFlag<boolean>('previewMarketplaceListingsEnabled')
@@ -177,15 +171,7 @@ export const useAvailableIntegrations = () => {
navigate: ({ pageId = 'overview' }) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('@/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab').then(
(mod) => mod.MarketplaceIntegrationOverviewTab
),
{
loading: Loading,
}
)
return null
case 'settings':
return dynamic(
() =>
@@ -5,14 +5,7 @@ import { useEffect, useMemo } from 'react'
import { useProjectOAuthIntegrationData } from './Landing.utils'
import { useAvailableIntegrations } from './useAvailableIntegrations'
import { useInstalledIntegrations } from './useInstalledIntegrations'
import {
areRequiredExtensionsInstalledFor,
getFilteredNavItems,
getInstallActionType,
type InstallActionType,
} from './useIntegrationDetail.utils'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { getInstallActionType, type InstallActionType } from './useIntegrationDetail.utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -27,13 +20,8 @@ export const useIntegrationDetail = () => {
const { ref, id, pageId, childId } = useParams()
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const { data: project } = useSelectedProjectQuery()
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: allIntegrations, isPending: isAvailableLoading } = useAvailableIntegrations()
const { installedIntegrations, isLoading: isInstalledLoading } = useInstalledIntegrations()
@@ -55,21 +43,7 @@ export const useIntegrationDetail = () => {
const isInstalled = !!integration && !!installation
const areRequiredExtensionsInstalled = useMemo(
() => areRequiredExtensionsInstalledFor(integration, extensions),
[integration, extensions]
)
const navItems = useMemo(
() =>
getFilteredNavItems({
integration,
isInstalled,
isMarketplaceEnabled,
areRequiredExtensionsInstalled,
}),
[integration, isInstalled, isMarketplaceEnabled, areRequiredExtensionsInstalled]
)
const navItems = useMemo(() => integration?.navigation ?? [], [integration])
const activeRoute = pageId ?? 'overview'
@@ -102,14 +76,8 @@ export const useIntegrationDetail = () => {
)
const installActionType: InstallActionType = useMemo(
() =>
getInstallActionType({
integration,
isMarketplaceEnabled,
areRequiredExtensionsInstalled,
isInstalled,
}),
[integration, isMarketplaceEnabled, areRequiredExtensionsInstalled, isInstalled]
() => getInstallActionType({ integration, isInstalled }),
[integration, isInstalled]
)
const isReady = !!router?.isReady
@@ -151,7 +119,6 @@ export const useIntegrationDetail = () => {
installation,
integrationStatus,
isInstalled,
areRequiredExtensionsInstalled,
installActionType,
wrappersTabHref,
isAvailableLoading,
@@ -1,65 +1,20 @@
import type { IntegrationDefinition, Navigation } from './Integrations.constants'
import type { IntegrationDefinition } from './Integrations.constants'
export type InstallActionType = 'oauth' | 'add-wrapper' | 'installed' | 'install-sheet' | null
export type DatabaseExtension = {
name: string
installed_version: string | null
}
/**
* Returns whether all required extensions for an integration are enabled in the database.
*/
export function areRequiredExtensionsInstalledFor(
integration: IntegrationDefinition | undefined,
extensions: DatabaseExtension[] | undefined
): boolean {
if (!integration?.requiredExtensions?.length || !extensions) return false
return integration.requiredExtensions.every(
(name) => !!extensions.find((ext) => ext.name === name)?.installed_version
)
}
/**
* Returns the filtered navigation items for an integration based on install / feature-flag state.
* If the marketplace flag is off, only expose the Wrappers tab once extensions are installed
*/
export function getFilteredNavItems({
integration,
isInstalled,
isMarketplaceEnabled,
areRequiredExtensionsInstalled,
}: {
integration: IntegrationDefinition | undefined
isInstalled: boolean
isMarketplaceEnabled: boolean
areRequiredExtensionsInstalled: boolean
}): Navigation[] {
if (!integration?.navigation) return []
if (isInstalled || integration.type !== 'wrapper') return integration.navigation
if (isMarketplaceEnabled || areRequiredExtensionsInstalled) return integration.navigation
return integration.navigation.filter((nav) => nav.route !== 'wrappers')
}
/**
* Returns which install action type to render for an integration.
*/
export function getInstallActionType({
integration,
isMarketplaceEnabled,
areRequiredExtensionsInstalled,
isInstalled,
}: {
integration: IntegrationDefinition | undefined
isMarketplaceEnabled: boolean
areRequiredExtensionsInstalled: boolean
isInstalled: boolean
}): InstallActionType {
if (!integration) return null
if (integration.type === 'oauth') return 'oauth'
if (integration.type === 'wrapper' && (isMarketplaceEnabled || areRequiredExtensionsInstalled)) {
return 'add-wrapper'
}
if (integration.type === 'wrapper') return 'add-wrapper'
if (isInstalled) return 'installed'
return 'install-sheet'
}
@@ -1,52 +1,3 @@
import { useParams } from 'common'
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const QueuesContent = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: extensions = [] } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const isQueuesInstalled = !!extensions.find((x) => x.name === 'pgmq')?.installed_version
if (isExposed) return null
return (
<Admonition
type="default"
title="Queues can be managed via any Supabase client library or PostgREST endpoints"
>
<p>
You may choose to toggle the exposure of Queues through Data APIs via the queues settings
</p>
{isQueuesInstalled && (
<Button asChild variant="default" className="mt-2">
<Link href={`/project/${ref}/integrations/queues/settings`}>Manage queues settings</Link>
</Button>
)}
</Admonition>
)
}
export const QueuesOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
return <IntegrationOverviewTab actions={<QueuesContent />} />
}
export const QueuesOverviewTab = () => <RequiredExtensionsSection />
@@ -1,10 +1,3 @@
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const VaultOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
return <IntegrationOverviewTab />
}
export const VaultOverviewTab = () => <RequiredExtensionsSection />
@@ -1,99 +1,3 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { toast } from 'sonner'
import { Admonition } from 'ui-patterns/admonition'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { NoPermission } from '@/components/ui/NoPermission'
import { useHooksEnableMutation } from '@/data/database/hooks-enable-mutation'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const WebhooksContent = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const {
data: schemas,
isSuccess: isSchemasLoaded,
refetch,
} = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions')
const { can: canReadWebhooks, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_READ,
'triggers'
)
const { mutate: enableHooks, isPending: isEnablingHooks } = useHooksEnableMutation({
onSuccess: async () => {
await refetch()
toast.success('Successfully enabled webhooks')
},
})
const enableHooksForProject = async () => {
if (!projectRef) return console.error('Project ref is required')
enableHooks({ ref: projectRef })
}
if (!isSchemasLoaded || isLoadingPermissions) {
return (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
}
if (!canReadWebhooks) {
return (
<div className="p-10">
<NoPermission isFullPage resourceText="view database webhooks" />
</div>
)
}
if (isSchemasLoaded && isHooksEnabled) return null
return (
<Admonition showIcon={false} type="default" title="Enable database webhooks on your project">
<p>
Database Webhooks can be used to trigger serverless functions or send requests to an HTTP
endpoint
</p>
<ButtonTooltip
className="mt-2 w-fit"
onClick={() => enableHooksForProject()}
disabled={isEnablingHooks}
tooltip={{
content: {
side: 'bottom',
text: !canReadWebhooks
? 'You need additional permissions to enable webhooks'
: undefined,
},
}}
>
Enable webhooks
</ButtonTooltip>
</Admonition>
)
}
export const WebhooksOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return <RequiredExtensionsSection />
}
return <IntegrationOverviewTab hideRequiredExtensionsSection actions={<WebhooksContent />} />
}
export const WebhooksOverviewTab = () => <RequiredExtensionsSection />
@@ -32,7 +32,6 @@ import {
NewTable,
} from './Wrappers.utils'
import WrapperTableEditor from './WrapperTableEditor'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { getExtensionDefaultSchema } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/IntegrationOverviewTabV2.utils'
import { RequiredExtensionsSection } from '@/components/interfaces/Integrations/Integration/RequiredExtensionsSection'
import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail'
@@ -66,7 +65,6 @@ export const CreateWrapperSheet = ({
onCloseWithConfirmation,
}: CreateWrapperSheetProps) => {
const queryClient = useQueryClient()
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const { integration } = useIntegrationDetail()
const { data: project } = useSelectedProjectQuery()
@@ -85,8 +83,8 @@ export const CreateWrapperSheet = ({
const wrappersExtension = extensions?.find((ext) => ext.name === 'wrappers')
const hasRequiredVersionForeignSchema = hasForeignSchemaSupport(wrappersExtension)
const needsExtensions = isMarketplaceEnabled && (requiredExtensionsToInstall?.length ?? 0) > 0
const isExtensionDataLoading = isMarketplaceEnabled && requiredExtensionsToInstall === null
const needsExtensions = (requiredExtensionsToInstall?.length ?? 0) > 0
const isExtensionDataLoading = requiredExtensionsToInstall === null
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref!,
@@ -261,18 +259,16 @@ export const CreateWrapperSheet = ({
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<div className="grow overflow-y-auto">
{isMarketplaceEnabled && (
<div className="px-5 py-5 flex flex-col gap-y-4 border-b">
{needsExtensions && (
<Admonition
type="warning"
title="Required extensions will be installed"
description="Proceeding will also install the extensions required by this wrapper."
/>
)}
<RequiredExtensionsSection hideSeparator />
</div>
)}
<div className="px-5 py-5 flex flex-col gap-y-4 border-b">
{needsExtensions && (
<Admonition
type="warning"
title="Required extensions will be installed"
description="Proceeding will also install the extensions required by this wrapper."
/>
)}
<RequiredExtensionsSection hideSeparator />
</div>
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
<FormField
@@ -1,206 +1,3 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import Link from 'next/link'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useState } from 'react'
import { Button, Sheet, SheetContent } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
import { useAvailableIntegrations } from '../Landing/useAvailableIntegrations'
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
import { CreateWrapperSheet } from './CreateWrapperSheet'
import { WRAPPERS } from './Wrappers.constants'
import { WrapperTable } from './WrapperTable'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
const WrapperOverviewContent = () => {
const { id } = useParams()
const wrapperMeta = WRAPPERS.find((w) => w.name === id)
const [isDirty, setIsDirty] = useState(false)
const [createWrapperShown, setCreateWrapperShown] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose: () => {
setCreateWrapperShown(false)
setIsDirty(false)
},
})
// [Joshen] Opting to declare custom wrapper sheets here instead of within Wrappers.constants.ts
// as we'll easily run into circular dependencies doing so unfortunately
const CreateWrapperSheetComponent = !wrapperMeta
? null
: wrapperMeta.customComponent
? wrapperMeta.name === 'iceberg_wrapper'
? CreateIcebergWrapperSheet
: null
: CreateWrapperSheet
return (
<>
<div className="flex flex-col gap-y-5">
<p>Recent wrappers</p>
<WrapperTable />
</div>
{!!CreateWrapperSheetComponent && !!wrapperMeta && (
<Sheet open={!!createWrapperShown} onOpenChange={handleOpenChange}>
<SheetContent size="lg" tabIndex={undefined}>
<CreateWrapperSheetComponent
wrapperMeta={wrapperMeta}
onDirty={setIsDirty}
onClose={() => setCreateWrapperShown(false)}
onCloseWithConfirmation={confirmOnClose}
/>
</SheetContent>
</Sheet>
)}
<DiscardChangesConfirmationDialog {...modalProps} />
</>
)
}
const AddNewWrapperCTA = () => {
const { id } = useParams()
const { data: project } = useSelectedProjectQuery()
const [, setCreateWrapperShown] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { can: canCreateWrapper } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'wrappers'
)
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrapperMeta = WRAPPERS.find((w) => w.name === id)
const wrappersExtension = data?.find((ext) => ext.name === 'wrappers')
const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version
const hasRequiredVersion =
(wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
// [Joshen] Default version is what's on the DB, so if the installed version is already the default version
// but still doesnt meet the minimum extension version, then DB upgrade is required
const databaseNeedsUpgrading =
wrappersExtension?.installed_version === wrappersExtension?.default_version
if (!!wrapperMeta && isWrappersExtensionInstalled && !hasRequiredVersion) {
return (
<Admonition type="warning" title="Your extension version is outdated for this wrapper">
<div className="flex flex-col gap-y-2 [&>p]:mb-0!">
<p>
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
{wrappersExtension?.installed_version} installed. Please{' '}
{databaseNeedsUpgrading && 'upgrade your database then '}update the extension by
disabling and enabling the <code className="text-code-inline">wrappers</code> extension
to create this wrapper.
</p>
<p className="text-warning">
Warning: Before reinstalling the wrapper extension, you must first remove all existing
wrappers. Afterward, you can recreate the wrappers.
</p>
</div>
<Button asChild variant="default" className="w-min mt-3">
<Link
href={
databaseNeedsUpgrading
? `/project/${project?.ref}/settings/infrastructure`
: `/project/${project?.ref}/database/extensions?filter=wrappers`
}
>
{databaseNeedsUpgrading ? 'Upgrade database' : 'View wrappers extension'}
</Link>
</Button>
</Admonition>
)
}
return (
<div className="py-3 px-5 border rounded-md">
<ButtonTooltip
variant="default"
onClick={() => setCreateWrapperShown(true)}
disabled={!canCreateWrapper}
tooltip={{
content: {
text: !canCreateWrapper
? 'You need additional permissions to create a foreign data wrapper'
: undefined,
},
}}
>
Add new wrapper
</ButtonTooltip>
</div>
)
}
export const WrapperContent = () => {
const { id } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: integrations = [] } = useAvailableIntegrations()
const integration = integrations.find((i) => i.id === id)
const wrapperMeta = WRAPPERS.find((w) => w.name === id)
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const installableExtensions = (extensions ?? []).filter((ext) =>
(integration?.requiredExtensions ?? []).includes(ext.name)
)
const isInstalled = installableExtensions.every((x) => x.installed_version)
if (!wrapperMeta) {
return (
<ScaffoldContainer>
<ScaffoldSection isFullWidth>
<p className="text-sm text-foreground-light">Unsupported integration type</p>
</ScaffoldSection>
</ScaffoldContainer>
)
}
return (
<>
<RequiredExtensionsSection />
<AddNewWrapperCTA />
{isInstalled && <WrapperOverviewContent />}
</>
)
}
export const WrapperOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
return (
<IntegrationOverviewTab actions={<AddNewWrapperCTA />}>
<div className="mx-10">
<WrapperOverviewContent />
</div>
</IntegrationOverviewTab>
)
}
export const WrapperOverviewTab = () => <RequiredExtensionsSection />
@@ -23,7 +23,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../../Integration/RequiredExtensionsSection'
import { InstallationError } from './InstallationError'
import { IntegrationInstalledActions, IntegrationNotInstalledActions } from './IntegrationActions'
@@ -38,7 +37,6 @@ import {
isUninstalling,
} from './stripe-sync-status'
import { StripeSyncChangesCard } from './StripeSyncChangesCard'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
import { useStripeSyncInstallMutation } from '@/data/database-integrations/stripe/stripe-sync-install-mutation'
import { useStripeSyncUninstallMutation } from '@/data/database-integrations/stripe/stripe-sync-uninstall-mutation'
@@ -391,23 +389,9 @@ const StripeSyncContent = ({ hideInstallCTA = false }: { hideInstallCTA?: boolea
)
}
export const StripeSyncEngineOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return (
<>
<RequiredExtensionsSection />
<StripeSyncContent hideInstallCTA />
</>
)
}
return (
<IntegrationOverviewTab>
<div className="px-4 md:px-10 max-w-4xl space-y-4">
<StripeSyncContent />
</div>
</IntegrationOverviewTab>
)
}
export const StripeSyncEngineOverviewTab = () => (
<>
<RequiredExtensionsSection />
<StripeSyncContent hideInstallCTA />
</>
)
@@ -1,174 +0,0 @@
import { IS_PLATFORM, useFeatureFlags, useParams } from 'common'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { Menu, Separator } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { ProjectLayout } from '@/components/layouts/ProjectLayout'
import { AlertError } from '@/components/ui/AlertError'
import { ProductMenu } from '@/components/ui/ProductMenu'
import { useMarketplaceCategoriesQuery } from '@/data/marketplace/integration-categories-query'
import { useMarketplaceIntegrationsQuery } from '@/data/marketplace/integrations-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { withAuth } from '@/hooks/misc/withAuth'
/**
* Layout component for the Integrations section
* Provides sidebar navigation for integrations
*/
export const ProjectIntegrationsLayout = withAuth(({ children }: PropsWithChildren) => {
const router = useRouter()
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]}` : ''}`
return (
<ProjectLayout
product="Integrations"
browserTitle={{ section: 'Integrations' }}
isBlocking={false}
productMenu={
<>
<IntegrationCategoriesMenu page={page} />
<Separator />
<InstalledIntegrationsMenu page={page} />
</>
}
>
{children}
</ProjectLayout>
)
})
const IntegrationCategoriesMenu = ({ page }: { page: string }) => {
const router = useRouter()
const { ref } = useParams()
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const urlParams = new URLSearchParams(router.asPath.split('?')[1] || '')
const categoryParam = urlParams.get('category')
const pageKey = categoryParam || page
const { integrationsWrappers: showWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const { data: categories = [], isPending: isPendingCategories } = useMarketplaceCategoriesQuery({
enabled: isMarketplaceEnabled,
})
const { data: listings = [], isPending: isPendingListings } = useMarketplaceIntegrationsQuery({
enabled: isMarketplaceEnabled,
})
const populatedCategoryIds = new Set(
listings.flatMap((listing) => listing.categories.map((c) => c.id))
)
const nonEmptyCategories = categories.filter(
(category) => category.id && populatedCategoryIds.has(category.id)
)
const isLoading = IS_PLATFORM
? !flagsLoaded || (isMarketplaceEnabled && (isPendingCategories || isPendingListings))
: false
const allCategories = [
{
name: 'All',
key: 'integrations',
url: `/project/${ref}/integrations`,
pages: ['integrations'],
items: [],
},
...(showWrappers
? [
{
name: 'Wrappers',
key: 'wrapper',
url: `/project/${ref}/integrations?category=wrapper`,
items: [],
},
]
: []),
{
name: 'Postgres Modules',
key: 'postgres_extension',
url: `/project/${ref}/integrations?category=postgres_extension`,
items: [],
},
...nonEmptyCategories.map((category) => ({
name: category.name ?? '',
key: category.slug ?? '',
url: `/project/${ref}/integrations?category=${category.slug}`,
items: [],
})),
]
return (
<>
{isLoading ? (
<div className="px-4 py-6 md:px-6">
<Menu type="pills">
<Menu.Group title={<span className="uppercase font-mono">Explore</span>} />
</Menu>
<GenericSkeletonLoader />
</div>
) : (
<ProductMenu
page={pageKey}
menu={[{ key: 'explore', title: 'Explore', items: allCategories }]}
/>
)}
</>
)
}
const InstalledIntegrationsMenu = ({ page }: { page: string }) => {
const { ref } = useParams()
const {
installedIntegrations: integrations,
error,
isLoading,
isSuccess,
isError,
} = useInstalledIntegrations()
const installedIntegrationItems = integrations.map((integration) => ({
name: integration.name,
label: integration.status,
key: `integrations/${integration.id}`,
url: `/project/${ref}/integrations/${integration.id}/overview`,
icon: (
<div className="relative w-6 h-6 bg-white border rounded-sm flex items-center justify-center">
{integration.icon({ className: 'p-1' })}
</div>
),
items: [],
}))
return (
<>
{(isLoading || isError) && (
<div className="px-4 py-6 md:px-6">
<Menu type="pills">
<Menu.Group title={<span className="uppercase font-mono">Installed</span>} />
</Menu>
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError
showIcon={false}
error={error}
subject="Failed to retrieve installed integrations"
/>
)}
</div>
)}
{isSuccess && (
<ProductMenu
page={page}
menu={[{ key: 'installed', title: 'Installed', items: installedIntegrationItems }]}
/>
)}
</>
)
}
@@ -1,13 +0,0 @@
import type { PropsWithChildren } from 'react'
import { ProjectIntegrationsLayout } from './ProjectIntegrationsLayout'
import { ProjectMarketplaceLayout } from './ProjectMarketplaceLayout'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
export const ProjectIntegrationsLayoutDispatch = ({ children }: PropsWithChildren) => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return <ProjectMarketplaceLayout>{children}</ProjectMarketplaceLayout>
}
return <ProjectIntegrationsLayout>{children}</ProjectIntegrationsLayout>
}
@@ -16,7 +16,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import { ProjectMarketplaceLayout } from '@/components/layouts/ProjectMarketplaceLayout'
import type { NextPageWithLayout } from '@/types'
const IntegrationPage: NextPageWithLayout = () => {
@@ -98,7 +98,7 @@ const IntegrationPage: NextPageWithLayout = () => {
IntegrationPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
<ProjectMarketplaceLayout>{page}</ProjectMarketplaceLayout>
</DefaultLayout>
)
@@ -1,19 +1,13 @@
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import LegacyIntegrationPage from '@/components/interfaces/Integrations/Integration/LegacyIntegrationPage'
import { MarketplaceDetail } from '@/components/interfaces/Integrations/Marketplace/MarketplaceDetail'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import { ProjectMarketplaceLayout } from '@/components/layouts/ProjectMarketplaceLayout'
import type { NextPageWithLayout } from '@/types'
const IntegrationPage: NextPageWithLayout = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <MarketplaceDetail />
return <LegacyIntegrationPage />
}
const IntegrationPage: NextPageWithLayout = () => <MarketplaceDetail />
IntegrationPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
<ProjectMarketplaceLayout>{page}</ProjectMarketplaceLayout>
</DefaultLayout>
)
@@ -6,7 +6,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import { ProjectMarketplaceLayout } from '@/components/layouts/ProjectMarketplaceLayout'
import type { NextPageWithLayout } from '@/types'
const INTEGRATION_FLAGS: Record<string, string> = {
@@ -57,7 +57,7 @@ const IntegrationPage: NextPageWithLayout = () => {
IntegrationPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
<ProjectMarketplaceLayout>{page}</ProjectMarketplaceLayout>
</DefaultLayout>
)
@@ -1,325 +1,13 @@
import { IS_PLATFORM, useFeatureFlags } from 'common'
import { Database } from 'common/marketplace.types'
import { Search } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsString, useQueryState } from 'nuqs'
import { useMemo, type ReactNode } from 'react'
import { Input } from 'ui-patterns/DataInputs/Input'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderAside,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent, PageSectionMeta } from 'ui-patterns/PageSection'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
IntegrationCard,
IntegrationLoadingCard,
} from '@/components/interfaces/Integrations/Landing/IntegrationCard'
import { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { useIntegrationFilteringAndSort } from '@/components/interfaces/Integrations/Landing/useIntegrationFilteringAndSort'
import { MarketplaceIndex } from '@/components/interfaces/Integrations/Marketplace/MarketplaceIndex'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { NoSearchResults } from '@/components/ui/NoSearchResults'
import { useMarketplaceCategoriesQuery } from '@/data/marketplace/integration-categories-query'
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
import { ProjectMarketplaceLayout } from '@/components/layouts/ProjectMarketplaceLayout'
import type { NextPageWithLayout } from '@/types'
const FEATURED_INTEGRATIONS = ['cron', 'queues', 'stripe_sync_engine']
// Featured integration images
const FEATURED_INTEGRATION_IMAGES: Record<string, string> = {
cron: `${BASE_PATH}/img/integrations/covers/cron-cover.webp`,
queues: `${BASE_PATH}/img/integrations/covers/queues-cover.png`,
stripe_wrapper: `${BASE_PATH}/img/integrations/covers/stripe-cover.png`,
stripe_sync_engine: `${BASE_PATH}/img/integrations/covers/stripe-cover.png`,
}
function getIntegrationImage(integration: IntegrationDefinition) {
let featured_image = FEATURED_INTEGRATION_IMAGES[integration.id]
if (featured_image) {
return featured_image
}
if (integration.files?.length) {
const heroImage = integration?.files?.[0]
return heroImage?.src ?? undefined
}
}
type PageContent = {
title: string
subtitle: string
secondaryActions?: ReactNode
}
const DEFAULT_PAGE_CONTENT: PageContent = {
title: 'Extend your database',
subtitle:
'Extensions and wrappers that add functionality to your database and connect to external services.',
}
const CATEGORY_PAGE_CONTENT = {
wrapper: {
title: 'Wrappers',
subtitle:
'Connect to external data sources and services by querying APIs, databases, and files as if they were Postgres tables.',
secondaryActions: (
<DocsButton href={`${DOCS_URL}/guides/database/extensions/wrappers/overview`} />
),
},
postgres_extension: {
title: 'Postgres Modules',
subtitle: 'Extend your database with powerful Postgres extensions.',
},
} satisfies Record<string, PageContent>
// Converts a category string to title
// Example: some_catory -> Some Category
function formatCategoryTitle(category: string) {
return category
.split(/[-_]/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const PageHeaderContentSkeleton = () => (
<div className="flex flex-col gap-y-2">
<ShimmeringLoader className="h-8 w-48" />
<ShimmeringLoader className="h-4 w-full max-w-xl" />
</div>
)
// Returns selected category to filter by
function useFilterCategory() {
const router = useRouter()
const [selectedCategory] = useQueryState(
'category',
parseAsString.withDefault('all').withOptions({ clearOnDefault: true })
)
const categoryFromUrl = useMemo(() => {
const queryString = router.asPath.split('?')[1]
if (!queryString) return null
return new URLSearchParams(queryString).get('category')
}, [router.asPath])
const resolvedSelectedCategory = router.isReady
? (categoryFromUrl ?? selectedCategory)
: undefined
const filterCategory = resolvedSelectedCategory ?? 'all'
return filterCategory
}
// Dynamic page content based on selected category
function usePageContent(
integrationFilterCategory: string,
categories: Database['public']['Views']['categories']['Row'][]
) {
const pageContent = useMemo<PageContent>(() => {
if (integrationFilterCategory === 'all') {
return DEFAULT_PAGE_CONTENT
}
if (integrationFilterCategory in CATEGORY_PAGE_CONTENT) {
return CATEGORY_PAGE_CONTENT[integrationFilterCategory as keyof typeof CATEGORY_PAGE_CONTENT]
}
const selectedMarketplaceCategory = categories.find(
(category) => category.slug === integrationFilterCategory
)
return {
title: selectedMarketplaceCategory?.name ?? formatCategoryTitle(integrationFilterCategory),
subtitle: selectedMarketplaceCategory?.description ?? DEFAULT_PAGE_CONTENT.subtitle,
}
}, [categories, integrationFilterCategory])
return pageContent
}
const IntegrationsPage: NextPageWithLayout = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <MarketplaceIndex />
return <LegacyIntegrationsPage />
}
const LegacyIntegrationsPage = () => {
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const isMarketplaceEnabled = useIsMarketplaceEnabled()
const [search, setSearch] = useQueryState(
'search',
parseAsString.withDefault('').withOptions({ clearOnDefault: true })
)
const {
data: availableIntegrations,
error,
isPending: isLoadingAvailableIntegrations,
isError,
isSuccess: isSuccessAvailableIntegrations,
} = useAvailableIntegrations()
const {
installedIntegrations,
isLoading: isLoadingInstalledIntegrations,
isSuccess: isSuccessInstalledIntegrations,
} = useInstalledIntegrations()
const installedIds = installedIntegrations.map((i) => i.id)
const isLoading = isLoadingAvailableIntegrations || isLoadingInstalledIntegrations
const isSuccess = isSuccessAvailableIntegrations && isSuccessInstalledIntegrations
const selectedCategory = useFilterCategory()
const { data: categories = [], isPending: isPendingCategories } = useMarketplaceCategoriesQuery({
enabled: isMarketplaceEnabled,
})
const isLoadingSelectedCategory =
selectedCategory !== 'all' &&
!(selectedCategory in CATEGORY_PAGE_CONTENT) &&
(IS_PLATFORM ? !flagsLoaded || (isMarketplaceEnabled && isPendingCategories) : false)
const pageContent = usePageContent(selectedCategory, categories)
const filteredIntegrations = useMemo(() => {
let filtered = availableIntegrations ?? []
if (selectedCategory !== 'all') {
filtered = filtered.filter(
(i) => i.type === selectedCategory || i.categories?.includes(selectedCategory)
)
}
if (search.length > 0) {
filtered = filtered.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
}
return filtered
}, [availableIntegrations, selectedCategory, search])
const hasActiveFilter = selectedCategory !== 'all' || search.length > 0
const { sorted: filteredAndSortedIntegrations, featured: featuredIntegrations } =
useIntegrationFilteringAndSort(filteredIntegrations, availableIntegrations, installedIds, {
featuredIds: FEATURED_INTEGRATIONS,
hasActiveFilter,
includeFeaturedFlag: true,
})
return (
<>
<PageHeader size="large">
<PageHeaderMeta>
<PageHeaderSummary>
{isLoadingSelectedCategory ? (
<PageHeaderContentSkeleton />
) : (
<>
<PageHeaderTitle>{pageContent.title}</PageHeaderTitle>
<PageHeaderDescription>{pageContent.subtitle}</PageHeaderDescription>
</>
)}
</PageHeaderSummary>
{pageContent.secondaryActions && (
<PageHeaderAside>{pageContent.secondaryActions}</PageHeaderAside>
)}
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large">
<PageSection>
<PageSectionMeta>
<Input
value={search}
size="tiny"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search integrations..."
icon={<Search size={14} />}
className="w-52"
/>
</PageSectionMeta>
<PageSectionContent>
{isLoading && (
<div
className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3"
style={{ gridAutoRows: 'minmax(110px, auto)' }}
>
{Array.from({ length: 8 }).map((_, idx) => (
<IntegrationLoadingCard key={`integration-loading-${idx}`} />
))}
</div>
)}
{/* Error State */}
{isError && (
<AlertError subject="Failed to retrieve available integrations" error={error} />
)}
{/* Success State */}
{isSuccess && (
<>
{/* No Search Results */}
{search.length > 0 && filteredAndSortedIntegrations.length === 0 && (
<NoSearchResults searchString={search} onResetFilter={() => setSearch('')} />
)}
{/* Featured Integrations */}
{featuredIntegrations && featuredIntegrations.length > 0 && (
<div
className="grid grid-cols-2 @4xl:grid-cols-3 gap-4 mb-4 items-stretch pb-6 border-b"
style={{ gridAutoRows: 'minmax(110px, auto)' }}
>
{featuredIntegrations.map((integration) => (
<IntegrationCard
key={integration.id}
{...integration}
isInstalled={installedIds.includes(integration.id)}
featured={true}
image={getIntegrationImage(integration)}
/>
))}
</div>
)}
{/* All Filtered and Sorted Integrations */}
{filteredAndSortedIntegrations.length > 0 && (
<div className="grid @xl:grid-cols-3 @6xl:grid-cols-4 gap-4">
{filteredAndSortedIntegrations.map((integration) => (
<IntegrationCard
key={integration.id}
{...integration}
isInstalled={installedIds.includes(integration.id)}
featured={false}
image={FEATURED_INTEGRATION_IMAGES[integration.id]}
/>
))}
</div>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
const IntegrationsPage: NextPageWithLayout = () => <MarketplaceIndex />
IntegrationsPage.getLayout = (page) => (
<DefaultLayout>
<ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
<ProjectMarketplaceLayout>{page}</ProjectMarketplaceLayout>
</DefaultLayout>
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

@@ -1,6 +1,6 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
import { ProjectMarketplaceLayout } from '@/components/layouts/ProjectMarketplaceLayout'
export const Route = createFileRoute('/project/$ref/integrations')({
component: IntegrationsShell,
@@ -8,8 +8,8 @@ export const Route = createFileRoute('/project/$ref/integrations')({
function IntegrationsShell() {
return (
<ProjectIntegrationsLayoutDispatch>
<ProjectMarketplaceLayout>
<Outlet />
</ProjectIntegrationsLayoutDispatch>
</ProjectMarketplaceLayout>
)
}
@@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
import {
areRequiredExtensionsInstalledFor,
getFilteredNavItems,
getInstallActionType,
} from '@/components/interfaces/Integrations/Landing/useIntegrationDetail.utils'
import { getInstallActionType } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail.utils'
// Minimal stubs — only fields exercised by the utils
const oauthIntegration = {
@@ -32,235 +28,44 @@ const extensionIntegration = {
navigation: [{ route: 'overview', label: 'Overview' }],
} as unknown as IntegrationDefinition
// ─── areRequiredExtensionsInstalledFor ───────────────────────────────────────
describe('areRequiredExtensionsInstalledFor', () => {
it('returns false when integration is undefined', () => {
expect(areRequiredExtensionsInstalledFor(undefined, [])).toBe(false)
})
it('returns false when extensions list is undefined', () => {
expect(areRequiredExtensionsInstalledFor(extensionIntegration, undefined)).toBe(false)
})
it('returns false when integration has no required extensions', () => {
const noExts = {
...extensionIntegration,
requiredExtensions: [],
} as unknown as IntegrationDefinition
expect(
areRequiredExtensionsInstalledFor(noExts, [{ name: 'pgmq', installed_version: '1.0' }])
).toBe(false)
})
it('returns false when a required extension is not installed', () => {
expect(
areRequiredExtensionsInstalledFor(extensionIntegration, [
{ name: 'pgmq', installed_version: null },
])
).toBe(false)
})
it('returns false when a required extension is missing from list entirely', () => {
expect(areRequiredExtensionsInstalledFor(extensionIntegration, [])).toBe(false)
})
it('returns true when all required extensions are installed', () => {
expect(
areRequiredExtensionsInstalledFor(extensionIntegration, [
{ name: 'pgmq', installed_version: '1.4.4' },
])
).toBe(true)
})
it('returns true only when every required extension is installed (multiple)', () => {
expect(
areRequiredExtensionsInstalledFor(wrapperIntegration, [
{ name: 'wrappers', installed_version: '0.4.1' },
{ name: 'supabase_vault', installed_version: '0.2.8' },
])
).toBe(true)
})
it('returns false when only some of multiple required extensions are installed', () => {
expect(
areRequiredExtensionsInstalledFor(wrapperIntegration, [
{ name: 'wrappers', installed_version: '0.4.1' },
{ name: 'supabase_vault', installed_version: null },
])
).toBe(false)
})
})
// ─── getFilteredNavItems ─────────────────────────────────────────────────────
describe('getFilteredNavItems', () => {
it('returns empty array when integration is undefined', () => {
expect(
getFilteredNavItems({
integration: undefined,
isInstalled: false,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
})
).toEqual([])
})
it('returns empty array when integration has no navigation', () => {
const noNav = {
...wrapperIntegration,
navigation: undefined,
} as unknown as IntegrationDefinition
expect(
getFilteredNavItems({
integration: noNav,
isInstalled: false,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
})
).toEqual([])
})
it('returns full navigation for non-wrapper integrations regardless of flags', () => {
const result = getFilteredNavItems({
integration: extensionIntegration,
isInstalled: false,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
})
expect(result).toEqual(extensionIntegration.navigation)
})
it('returns full navigation for installed wrapper integrations', () => {
const result = getFilteredNavItems({
integration: wrapperIntegration,
isInstalled: true,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
})
expect(result).toEqual(wrapperIntegration.navigation)
})
it('hides wrappers tab for uninstalled wrapper when marketplace is off and extensions missing', () => {
const result = getFilteredNavItems({
integration: wrapperIntegration,
isInstalled: false,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
})
expect(result.some((nav) => nav.route === 'wrappers')).toBe(false)
expect(result.some((nav) => nav.route === 'overview')).toBe(true)
})
it('shows wrappers tab when marketplace flag is enabled', () => {
const result = getFilteredNavItems({
integration: wrapperIntegration,
isInstalled: false,
isMarketplaceEnabled: true,
areRequiredExtensionsInstalled: false,
})
expect(result).toEqual(wrapperIntegration.navigation)
})
it('shows wrappers tab when required extensions are installed', () => {
const result = getFilteredNavItems({
integration: wrapperIntegration,
isInstalled: false,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: true,
})
expect(result).toEqual(wrapperIntegration.navigation)
})
})
// ─── getInstallActionType ────────────────────────────────────────────────────
describe('getInstallActionType', () => {
it('returns null when integration is undefined', () => {
expect(
getInstallActionType({
integration: undefined,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
isInstalled: false,
})
).toBeNull()
expect(getInstallActionType({ integration: undefined, isInstalled: false })).toBeNull()
})
it('returns "oauth" for OAuth integrations', () => {
expect(
getInstallActionType({
integration: oauthIntegration,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
isInstalled: false,
})
).toBe('oauth')
expect(getInstallActionType({ integration: oauthIntegration, isInstalled: false })).toBe(
'oauth'
)
})
it('returns "oauth" for installed OAuth integrations (type check takes priority)', () => {
expect(
getInstallActionType({
integration: oauthIntegration,
isMarketplaceEnabled: true,
areRequiredExtensionsInstalled: true,
isInstalled: true,
})
).toBe('oauth')
expect(getInstallActionType({ integration: oauthIntegration, isInstalled: true })).toBe(
'oauth'
)
})
it('returns "add-wrapper" for wrapper when marketplace is enabled', () => {
expect(
getInstallActionType({
integration: wrapperIntegration,
isMarketplaceEnabled: true,
areRequiredExtensionsInstalled: false,
isInstalled: false,
})
).toBe('add-wrapper')
it('returns "add-wrapper" for wrapper integrations', () => {
expect(getInstallActionType({ integration: wrapperIntegration, isInstalled: false })).toBe(
'add-wrapper'
)
})
it('returns "add-wrapper" for wrapper when required extensions are installed', () => {
expect(
getInstallActionType({
integration: wrapperIntegration,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: true,
isInstalled: false,
})
).toBe('add-wrapper')
it('returns "add-wrapper" for installed wrapper integrations (type check takes priority)', () => {
expect(getInstallActionType({ integration: wrapperIntegration, isInstalled: true })).toBe(
'add-wrapper'
)
})
it('returns "installed" for a non-wrapper installed integration when marketplace is off', () => {
expect(
getInstallActionType({
integration: extensionIntegration,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
isInstalled: true,
})
).toBe('installed')
it('returns "installed" for a non-wrapper installed integration', () => {
expect(getInstallActionType({ integration: extensionIntegration, isInstalled: true })).toBe(
'installed'
)
})
it('returns "install-sheet" for an uninstalled non-wrapper integration', () => {
expect(
getInstallActionType({
integration: extensionIntegration,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
isInstalled: false,
})
).toBe('install-sheet')
})
it('returns "install-sheet" for wrapper when marketplace off and extensions missing and not installed', () => {
expect(
getInstallActionType({
integration: wrapperIntegration,
isMarketplaceEnabled: false,
areRequiredExtensionsInstalled: false,
isInstalled: false,
})
).toBe('install-sheet')
expect(getInstallActionType({ integration: extensionIntegration, isInstalled: false })).toBe(
'install-sheet'
)
})
})
@@ -26,7 +26,6 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks',
UI_PREVIEW_JIT_DB_ACCESS: 'supabase-ui-jit-db-access',
UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE: 'supabase-ui-sql-editor-manual-save',
UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace',
AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in',
SIGN_IN_CHATGPT_ENABLED: 'siwc-enabled',
@@ -155,7 +154,6 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,