Files
supabase__supabase/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx
Danny White 1755580dcf feat(studio): move Warehouse setup into Integrations (#50247)
## What kind of change does this PR introduce?

Feature and information architecture change. Builds on #50246.

## What is the current behaviour?

Warehouse setup, progress, errors, table selection, and connection
details all live in the transient Connect sheet. Closing the sheet hides
the current replication state, and the integration is absent from the
Integrations page.

## What is the new behaviour?

Warehouse now has a persistent Overview page at
`/project/{ref}/integrations/warehouse/overview`:

- Before setup, the existing schema and table picker enables Warehouse.
- During setup, the page shows the current phase and per-table backfill
state where available.
- Setup and status failures remain visible on the page with retry
actions where possible.
- Once complete, the page shows Status, Tables, then Connect.
- The Connect sheet becomes read-only. Before Warehouse is ready, it
links directly to the Overview page for setup, progress, or recovery.

| Before | After |
| --- | --- |
| <img width="1200" height="907" alt="Chives Pantry Supabase"
src="https://github.com/user-attachments/assets/82dc7fb0-3859-499e-96ab-56f70a5c7325"
/> | <img width="1200" height="907" alt="Chives Pantry Supabase"
src="https://github.com/user-attachments/assets/7428e301-27f8-48fe-91e0-6880b132920d"
/> |
| <img width="1200" height="907" alt="Chives Pantry Supabase"
src="https://github.com/user-attachments/assets/82dc7fb0-3859-499e-96ab-56f70a5c7325"
/> | <img width="1200" height="907" alt="54709"
src="https://github.com/user-attachments/assets/0c589e5b-e13c-4554-a6ee-6730d0e95c07"
/> |
| <img width="1200" height="907" alt="ETL BigTable ETL Team Supabase"
src="https://github.com/user-attachments/assets/18ec9f6c-3724-453f-bbbb-c7149758246e"
/> | <img width="1200" height="907" alt="Regular AWS Teamer Supabase"
src="https://github.com/user-attachments/assets/3819c9fe-e56d-4cec-988d-5e724f8d990a"
/> |

## To test

Use a project whose organisation is included in the Warehouse
allow-list.

1. Open `/project/{ref}/integrations`, filter by **Data platform**, and
open Warehouse.
2. Before setup, confirm the existing schema and table picker appears
and starts with no tables selected.
3. Start setup and confirm the Status section polls through setup and
table backfill phases.
4. Confirm setup failures remain visible and expose Retry when the API
returns affected tables.
5. After setup, confirm the section order is Status, Tables, Connect.
6. Confirm existing replicated tables are selected and locked, while
additional tables can be added.
7. Open `/project/{ref}?showConnect=true&connectTab=warehouse` and
confirm it links to the Overview page before setup, during setup, and
after a setup failure.
8. Once setup is complete, confirm the Connect sheet shows the FlightSQL
and DuckDB connection controls from #50246.
9. Repeat the Overview checks with **One-Click Integrations** turned
off.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Supabase Warehouse to the integrations catalog, with overview
documentation and availability-aware display.
* Added Warehouse setup and management flows, including schema and table
selection, replication progress, status details, connection options, and
retry actions.
* Added DuckDB and FlightSQL engine selection with Connect sheet URL and
preference synchronization.
* Added table replication status, lag, timestamps, and size information.

* **Bug Fixes**
* Warehouse setup status requests no longer retry automatically after
failures.
* Improved recovery messaging and retry behavior for setup and
connection errors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-15 12:38:13 +10:00

294 lines
10 KiB
TypeScript

import {
FeatureFlagContext,
FeatureFlagContextType,
IS_PLATFORM,
useFeatureFlags,
useFlag,
} from 'common'
import { fullImageUrl } from 'common/marketplace-client'
import { Boxes } from 'lucide-react'
import dynamic from 'next/dynamic'
import Image from 'next/image'
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,
} from '@/data/marketplace/integrations-query'
import { useCLIReleaseVersionQuery } from '@/data/misc/cli-release-version-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
const renderMarketplaceLogo = (listingLogo?: string | null) => {
const MarketplaceLogo = ({ className, ...props }: { className?: string } = {}) => (
<div className="relative w-full h-full">
{listingLogo ? (
<Image
fill
src={fullImageUrl(listingLogo)}
alt=""
className={cn('p-2', className)}
{...props}
/>
) : (
<Boxes className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
)}
</div>
)
return MarketplaceLogo
}
function isForeignDataWrapper(integration: MarketplaceIntegration) {
return integration.categories.some((c) => c?.slug === 'foreign-data-wrapper')
}
/**
* Use per-listing feature flags with a templated naming convention in order to independently
* enable/disable previews for users where the global `previewMarketplaceListingsEnabled` flag is not set.
*/
const isPreviewEnabled = (featureFlags: FeatureFlagContextType, listingSlug: string) => {
const flagName = `${listingSlug}DashboardIntegrationEnabled`
return (featureFlags.configcat[flagName] ?? false) as boolean
}
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
// This flag can globally enable preview listings for all partners for a given user (i.e. for Supabase users)
const previewAllListingsEnabled = useFlag<boolean>('previewMarketplaceListingsEnabled')
const featureFlags = useFeatureFlags()
const enabledListings = useMemo(
() =>
(marketplaceData ?? []).filter(
(integration) =>
integration.review_status === 'approved' ||
(integration.review_status === 'preview' &&
(previewAllListingsEnabled || isPreviewEnabled(featureFlags, integration.slug)))
),
[marketplaceData, previewAllListingsEnabled, featureFlags]
)
return { isPending, isSuccess, isError, data: enabledListings, error }
}
/**
* [Joshen] Returns a combination of
* - Marketplace integrations retrieved remotely (Only if feature flag enabled)
* - Existing integrations that are defined within studio
*/
export const useAvailableIntegrations = () => {
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const isWarehouseAvailable = useIsWarehouseEnabled()
const { data: cliData } = useCLIReleaseVersionQuery()
const isCLI = !!cliData?.current
const {
isPending,
isSuccess,
isError,
data: marketplaceListings,
error,
} = useMarketplaceListings()
// [Joshen] Format marketplace integrations into existing ones for now
// Likely that we might need to change, but can look into separately
// Wrappers from marketplace are excluded here. They are merged into the
// hardcoded studio wrappers below as content overrides.
const marketplaceIntegrations: IntegrationDefinition[] = useMemo(
() =>
marketplaceListings
.filter((integration) => !isForeignDataWrapper(integration))
.map((integration) => {
const {
id: listingId,
slug,
categories,
featured,
title,
description,
documentation_url: docsUrl,
website_url: siteUrl,
installation_url: installUrl,
installation_url_type: installUrlType,
installation_identification_method: installMethod,
secret_key_prefix: secretKeyPrefix,
edge_function_secret_name: edgeFunctionSecretName,
images,
content,
built_by: authorName,
listing_logo: listingLogo,
oauth_app_id: oauthAppId,
} = integration
const status = undefined
const author = { name: authorName ?? '', websiteUrl: '' }
return {
id: slug ?? '',
name: title ?? '',
status,
featured: !!featured,
type: 'oauth' as const, // Currently marketplace only supports oauth apps
source: 'Partner' as const,
categories: categories.map((x) => x.slug),
content,
files: images?.map((image, i) => ({
src: fullImageUrl(image),
alt: `${title} image ${i + 1}`,
})),
description,
docsUrl,
siteUrl,
installUrl,
installUrlType: installUrlType ?? undefined,
installIdentificationMethod: installMethod ?? undefined,
secretKeyPrefix: secretKeyPrefix ?? undefined,
edgeFunctionSecretName: edgeFunctionSecretName ?? undefined,
oauthAppId: oauthAppId ?? undefined,
listingId: listingId ?? undefined,
author,
requiredExtensions: [],
icon: renderMarketplaceLogo(listingLogo),
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'settings',
label: 'Settings',
layout: 'constrained',
},
],
navigate: ({ pageId = 'overview' }) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('@/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab').then(
(mod) => mod.MarketplaceIntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'settings':
return dynamic(
() =>
import('@/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab').then(
(mod) => mod.MarketplaceIntegrationSettingsTab
),
{
loading: Loading,
}
)
}
return null
},
}
}),
[marketplaceListings]
)
// Marketplace wrapper listings keyed by their studio-equivalent id
// (marketplace uses dash-separated slugs, studio uses underscore-separated ids).
const marketplaceWrappers = useMemo(() => {
const map: Record<string, MarketplaceIntegration> = {}
marketplaceListings.forEach((integration) => {
if (!isForeignDataWrapper(integration)) return
map[integration.slug.replaceAll('-', '_')] = integration
})
return map
}, [marketplaceListings])
// [Joshen] Existing integrations that are defined within studio
// Available integrations are all integrations that can be installed. If an integration can't be installed (needed
// extensions are not available on this DB image), the UI will provide a tooltip explaining why.
const allIntegrations = useMemo(() => {
return INTEGRATIONS.filter((integration) => {
if (!integrationsWrappers && integration.type === 'wrapper') {
return false
}
if (integration.id === 'stripe_sync_engine' && isCLI) {
return false
}
// Warehouse is gated to an allow-list of orgs, and the API rejects every
// `/platform/warehouse/{ref}/*` call for the rest, so hide it entirely instead of showing an
// integration whose every request would fail.
if (integration.id === 'warehouse' && !isWarehouseAvailable) {
return false
}
return true
}).map((integration) => {
const isWrapper = integration.type === 'wrapper'
if (!isWrapper) return integration
const marketplaceWrapper = marketplaceWrappers[integration.id]
if (!marketplaceWrapper) return integration
const {
title,
description,
content,
documentation_url: docsUrl,
website_url: siteUrl,
images,
built_by: authorName,
listing_logo: listingLogo,
} = marketplaceWrapper
const overrides = {
name: title,
description,
content,
docsUrl,
siteUrl,
author: authorName ? { name: authorName, websiteUrl: '' } : undefined,
files: images?.map((image, i) => ({
src: fullImageUrl(image),
alt: `${title} screenshot ${i + 1}`,
})),
icon: listingLogo ? renderMarketplaceLogo(listingLogo) : undefined,
}
return {
...integration,
...Object.fromEntries(Object.entries(overrides).filter(([, v]) => v != null)),
}
})
}, [integrationsWrappers, isCLI, isWarehouseAvailable, marketplaceWrappers])
const dataWithMarketplace = useMemo(() => {
return [...marketplaceIntegrations, ...allIntegrations].sort((a, b) =>
a.name.localeCompare(b.name)
)
}, [marketplaceIntegrations, allIntegrations])
return {
data: dataWithMarketplace,
error,
isPending,
isSuccess,
isError,
}
}