From c7ab0c73704492c2599b8c4371cf535393c02a32 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 23 Jun 2026 11:16:58 -0400 Subject: [PATCH] feat(studio): marketplace preview listings (#47004) Updates the Studio integrations marketplace to support the new marketplace-specific database view and `preview` status --- .../Landing/useAvailableIntegrations.tsx | 99 ++++++++++++------- .../data/marketplace/integrations-query.ts | 6 +- packages/common/marketplace-client.ts | 19 ++++ packages/common/marketplace.types.ts | 43 ++++++++ 4 files changed, 129 insertions(+), 38 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index bd45de8960e..c0817a4b86c 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -1,4 +1,10 @@ -import { FeatureFlagContext, IS_PLATFORM, useFlag } from 'common' +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' @@ -38,28 +44,65 @@ 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('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 { hasLoaded } = useContext(FeatureFlagContext) - const isMarketplaceEnabled = useIsMarketplaceEnabled() const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) - const grafanaEnabled = useFlag('grafanaDashboardIntegrationEnabled') - const resendEnabled = useFlag('resendDashboardIntegrationEnabled') - const aikidoEnabled = useFlag('aikidoDashboardIntegrationEnabled') - const dopplerEnabled = useFlag('dopplerDashboardIntegrationEnabled') - const { data: cliData } = useCLIReleaseVersionQuery() const isCLI = !!cliData?.current - const { data, error } = useMarketplaceIntegrationsQuery({ enabled: isMarketplaceEnabled }) - const isPending = IS_PLATFORM && (!hasLoaded || (isMarketplaceEnabled && !data && !error)) - const isSuccess = !IS_PLATFORM || (hasLoaded && (!isMarketplaceEnabled || (!!data && !error))) - const isError = IS_PLATFORM && isMarketplaceEnabled && !!error + 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 @@ -67,8 +110,8 @@ export const useAvailableIntegrations = () => { // hardcoded studio wrappers below as content overrides. const marketplaceIntegrations: IntegrationDefinition[] = useMemo( () => - (data ?? []) - ?.filter((integration) => !isForeignDataWrapper(integration)) + marketplaceListings + .filter((integration) => !isForeignDataWrapper(integration)) .map((integration) => { const { id: listingId, @@ -158,19 +201,19 @@ export const useAvailableIntegrations = () => { }, } }), - [data] + [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 = {} - ;(data ?? []).forEach((integration) => { + marketplaceListings.forEach((integration) => { if (!isForeignDataWrapper(integration)) return map[integration.slug.replaceAll('-', '_')] = integration }) return map - }, [data]) + }, [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 @@ -226,24 +269,10 @@ export const useAvailableIntegrations = () => { }, [integrationsWrappers, isCLI, marketplaceWrappers]) const dataWithMarketplace = useMemo(() => { - const flagGatedIds: Record = { - grafana: grafanaEnabled, - resend: resendEnabled, - aikido: aikidoEnabled, - doppler: dopplerEnabled, - } - - return [...marketplaceIntegrations, ...allIntegrations] - .filter((integration) => flagGatedIds[integration.id] !== false) - .sort((a, b) => a.name.localeCompare(b.name)) - }, [ - marketplaceIntegrations, - allIntegrations, - grafanaEnabled, - resendEnabled, - aikidoEnabled, - dopplerEnabled, - ]) + return [...marketplaceIntegrations, ...allIntegrations].sort((a, b) => + a.name.localeCompare(b.name) + ) + }, [marketplaceIntegrations, allIntegrations]) return { data: dataWithMarketplace, diff --git a/apps/studio/data/marketplace/integrations-query.ts b/apps/studio/data/marketplace/integrations-query.ts index 23fab820637..588f1e8bb1a 100644 --- a/apps/studio/data/marketplace/integrations-query.ts +++ b/apps/studio/data/marketplace/integrations-query.ts @@ -1,16 +1,16 @@ import { useQuery } from '@tanstack/react-query' -import { createMarketplaceClient, type Listing } from 'common/marketplace-client' +import { createMarketplaceClient, type MarketplaceListing } from 'common/marketplace-client' import { marketplaceIntegrationsKeys } from './keys' import { handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' -export type MarketplaceIntegration = Listing +export type MarketplaceIntegration = MarketplaceListing export async function getMarketplaceIntegrations(signal?: AbortSignal) { const marketplaceClient = createMarketplaceClient() let query = marketplaceClient - .from('listings') + .from('marketplace_listings') .select('*') .not('published_in_marketplace_at', 'is', null) if (signal) query = query.abortSignal(signal) diff --git a/packages/common/marketplace-client.ts b/packages/common/marketplace-client.ts index db0b34749e1..50294821977 100644 --- a/packages/common/marketplace-client.ts +++ b/packages/common/marketplace-client.ts @@ -33,12 +33,31 @@ export type Database = MergeDeep< listing_logo: string } } + marketplace_listings: { + Row: { + // add a type for the JSON structure + categories: Category[] + // These all come from non-nullable columns but all view columns are inferred as nullable. + // See https://github.com/orgs/supabase/discussions/14151 + featured: boolean + partner_name: string + built_by: string + slug: string + title: string + description: string + content: string + website_url: string + documentation_url: string + listing_logo: string + } + } } } } > export type Listing = Database['public']['Views']['listings']['Row'] +export type MarketplaceListing = Database['public']['Views']['marketplace_listings']['Row'] export const createMarketplaceClient = () => { const API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL || '' diff --git a/packages/common/marketplace.types.ts b/packages/common/marketplace.types.ts index 2c27e642bd4..4f1ac93255b 100644 --- a/packages/common/marketplace.types.ts +++ b/packages/common/marketplace.types.ts @@ -73,6 +73,49 @@ export type Database = { }, ] } + marketplace_listings: { + Row: { + built_by: string | null + categories: Json | null + content: string | null + description: string | null + documentation_url: string | null + edge_function_secret_name: string | null + featured: boolean | null + id: string | null + images: string[] | null + installation_identification_method: + | 'secret_key_prefix' + | 'edge_function_secret_name' + | 'integration_status' + | 'oauth_authorization' + | null + installation_url: string | null + installation_url_type: 'get' | 'post' | null + listing_logo: string | null + oauth_app_id: string | null + partner_id: string | null + partner_logo: string | null + partner_name: string | null + partner_slug: string | null + published_in_marketplace_at: string | null + review_status: 'draft' | 'pending' | 'approved' | 'rejected' | 'preview' | null + secret_key_prefix: string | null + slug: string | null + title: string | null + website_url: string | null + youtube_id: string | null + } + Relationships: [ + { + foreignKeyName: 'listings_partner_id_fkey' + columns: ['partner_id'] + isOneToOne: false + referencedRelation: 'partners' + referencedColumns: ['id'] + }, + ] + } partners: { Row: { country: string | null