mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
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:
38 files changed
+80
-1890
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
+3
-10
@@ -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'
|
||||
+1
-3
@@ -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
|
||||
|
||||
-98
@@ -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')
|
||||
})
|
||||
})
|
||||
-103
@@ -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
|
||||
-11
@@ -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
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
-19
@@ -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,
|
||||
|
||||
+2
-47
@@ -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 />
|
||||
+6
-22
@@ -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>
|
||||
)
|
||||
}
|
||||
+23
-218
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user