mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): support ConfigCat targeting by project ref
This commit is contained in:
1 parent
4ab54b9359
commit
4ea568c0ea
4 files changed
+195
-88
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import type { Session } from '@supabase/supabase-js'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import { platformComponents as components } from 'api-types'
|
||||
import { AuthContext, useFlag } from 'common'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StudioFeatureFlagProvider } from './StudioFeatureFlagProvider'
|
||||
import { createMockOrganizationResponse } from '@/tests/helpers'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
|
||||
import { routerMock } from '@/tests/lib/route-mock'
|
||||
|
||||
const { getFlags } = vi.hoisted(() => ({ getFlags: vi.fn() }))
|
||||
|
||||
vi.mock('common', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('common')>()),
|
||||
getFlags,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/constants', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/constants')>()),
|
||||
IS_PLATFORM: true,
|
||||
}))
|
||||
|
||||
const SESSION: Session = {
|
||||
access_token: 'test-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 'test-user',
|
||||
aud: 'authenticated',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
const PROFILE_CONTEXT = createMockProfileContext()
|
||||
|
||||
function FlagValue() {
|
||||
const isEnabled = useFlag('projectTargetedFeature')
|
||||
return <div>{isEnabled ? 'Enabled' : 'Disabled'}</div>
|
||||
}
|
||||
|
||||
function TestProvider() {
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
session: SESSION,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refreshSession: () => Promise.resolve(SESSION),
|
||||
}}
|
||||
>
|
||||
<StudioFeatureFlagProvider enabled>
|
||||
<FlagValue />
|
||||
</StudioFeatureFlagProvider>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('StudioFeatureFlagProvider', () => {
|
||||
beforeEach(() => {
|
||||
getFlags.mockReset()
|
||||
getFlags.mockImplementation(async (_userEmail, customAttributes) => [
|
||||
{
|
||||
settingKey: 'projectTargetedFeature',
|
||||
settingValue: customAttributes.project_ref === 'project-a',
|
||||
},
|
||||
])
|
||||
routerMock.setCurrentUrl('/projects/project-a')
|
||||
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () =>
|
||||
HttpResponse.json<components['schemas']['OrganizationResponse_Output'][]>([
|
||||
createMockOrganizationResponse({ slug: 'test-org', plan: { id: 'pro', name: 'Pro' } }),
|
||||
]),
|
||||
})
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref',
|
||||
response: ({ params }) =>
|
||||
HttpResponse.json<components['schemas']['ProjectDetailResponse_Output']>({
|
||||
id: 1,
|
||||
ref: String(params.ref),
|
||||
name: 'Test project',
|
||||
organization_id: 1,
|
||||
cloud_provider: 'AWS',
|
||||
region: 'us-east-1',
|
||||
inserted_at: '2026-01-01T00:00:00Z',
|
||||
subscription_id: 'subscription-1',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
is_branch_enabled: false,
|
||||
is_physical_backups_enabled: false,
|
||||
connectionString: 'postgresql://postgres:password@localhost:5432/postgres',
|
||||
db_host: 'localhost',
|
||||
high_availability: false,
|
||||
integration_source: null,
|
||||
restUrl: 'https://example.supabase.co',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the project ref alongside existing email, cloud, and plan targeting', async () => {
|
||||
customRender(<TestProvider />, { profileContext: PROFILE_CONTEXT })
|
||||
|
||||
expect(await screen.findByText('Enabled')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(getFlags).toHaveBeenLastCalledWith(SESSION.user.email, {
|
||||
project_ref: 'project-a',
|
||||
cloud_provider: 'AWS',
|
||||
plan: 'pro',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('re-evaluates on project switches and removes project targeting on organization pages', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
customRender(<TestProvider />, { profileContext: PROFILE_CONTEXT, queryClient })
|
||||
expect(await screen.findByText('Enabled')).toBeInTheDocument()
|
||||
|
||||
await act(async () => routerMock.push('/projects/project-b'))
|
||||
expect(await screen.findByText('Disabled')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(getFlags).toHaveBeenLastCalledWith(
|
||||
SESSION.user.email,
|
||||
expect.objectContaining({ project_ref: 'project-b' })
|
||||
)
|
||||
})
|
||||
|
||||
await act(async () => routerMock.push('/organizations?slug=test-org'))
|
||||
await waitFor(() => {
|
||||
expect(getFlags).toHaveBeenLastCalledWith(SESSION.user.email, {
|
||||
cloud_provider: 'AWS',
|
||||
plan: 'pro',
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FeatureFlagProvider, getFlags, useParams } from 'common'
|
||||
import { useCallback, type ComponentProps } from 'react'
|
||||
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
|
||||
|
||||
export const StudioFeatureFlagProvider = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof FeatureFlagProvider>) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM })
|
||||
const cloudProvider = useDefaultProvider()
|
||||
|
||||
const getConfigCatFlags = useCallback(
|
||||
(userEmail?: string) => {
|
||||
const customAttributes: Record<string, string> = {}
|
||||
if (cloudProvider) customAttributes.cloud_provider = cloudProvider
|
||||
if (selectedOrganization?.plan?.id) customAttributes.plan = selectedOrganization.plan.id
|
||||
if (projectRef) customAttributes.project_ref = projectRef
|
||||
return getFlags(userEmail, customAttributes)
|
||||
},
|
||||
[cloudProvider, selectedOrganization?.plan?.id, projectRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<FeatureFlagProvider
|
||||
{...props}
|
||||
getConfigCatFlags={getConfigCatFlags}
|
||||
organizationSlug={selectedOrganization?.slug ?? undefined}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagProvider>
|
||||
)
|
||||
}
|
||||
@@ -15,13 +15,7 @@ import 'ui-patterns/ShimmeringLoader/index.css'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import {
|
||||
FeatureFlagProvider,
|
||||
getFlags,
|
||||
TelemetryTagManager,
|
||||
ThemeProvider,
|
||||
useThemeSandbox,
|
||||
} from 'common'
|
||||
import { TelemetryTagManager, ThemeProvider, useThemeSandbox } from 'common'
|
||||
import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router'
|
||||
import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
@@ -33,7 +27,7 @@ import { DevToolbar, DevToolbarProvider, DevToolbarTrigger, type ExtraTab } from
|
||||
import dynamic from 'next/dynamic'
|
||||
import Head from 'next/head'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
|
||||
import { ErrorInfo, useCallback, useEffect, useState, type ComponentProps } from 'react'
|
||||
import { ErrorInfo, useEffect, useState } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { TooltipProvider } from 'ui'
|
||||
import { TimestampInfoProvider } from 'ui-patterns/TimestampInfo'
|
||||
@@ -46,6 +40,7 @@ import { FeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/
|
||||
import { IndirectTaxDeclarationModal } from '@/components/interfaces/App/IndirectTaxDeclarationModal'
|
||||
import { MonacoThemeProvider } from '@/components/interfaces/App/MonacoThemeProvider'
|
||||
import { RouteValidationWrapper } from '@/components/interfaces/App/RouteValidationWrapper'
|
||||
import { StudioFeatureFlagProvider } from '@/components/interfaces/App/StudioFeatureFlagProvider'
|
||||
import { MainScrollContainerProvider } from '@/components/layouts/MainScrollContainerContext'
|
||||
import { BannerStackProvider } from '@/components/ui/BannerStack/BannerStackProvider'
|
||||
import { clearBootTimeoutFallback } from '@/components/ui/BootTimeoutFallback/BootTimeoutFallback'
|
||||
@@ -55,10 +50,9 @@ import { getCLIReleaseVersion } from '@/data/misc/cli-release-version-query'
|
||||
import { useRootQueryClient } from '@/data/query-client'
|
||||
import { inter, manrope, sourceCodePro } from '@/fonts'
|
||||
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { AuthProvider } from '@/lib/auth'
|
||||
import { configureMonacoLoader } from '@/lib/configure-monaco-loader'
|
||||
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
|
||||
import { API_URL, BASE_PATH, IS_PLATFORM } from '@/lib/constants'
|
||||
import { TimezoneProvider, useTimezone } from '@/lib/datetime'
|
||||
import { ProfileProvider } from '@/lib/profile'
|
||||
import { Telemetry } from '@/lib/telemetry'
|
||||
@@ -96,34 +90,6 @@ const devToolbarExtraTabs: ExtraTab[] = IS_DEV_TOOLBAR_ENABLED
|
||||
]
|
||||
: []
|
||||
|
||||
const FeatureFlagProviderWithOrgContext = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof FeatureFlagProvider>) => {
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM })
|
||||
const cloudProvider = useDefaultProvider()
|
||||
|
||||
const getConfigCatFlags = useCallback(
|
||||
(userEmail?: string) => {
|
||||
const customAttributes: Record<string, string> = {}
|
||||
if (cloudProvider) customAttributes.cloud_provider = cloudProvider
|
||||
if (selectedOrganization?.plan?.id) customAttributes.plan = selectedOrganization.plan.id
|
||||
return getFlags(userEmail, customAttributes)
|
||||
},
|
||||
[cloudProvider, selectedOrganization?.plan?.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<FeatureFlagProvider
|
||||
{...props}
|
||||
getConfigCatFlags={getConfigCatFlags}
|
||||
organizationSlug={selectedOrganization?.slug ?? undefined}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const TimestampInfoTimezoneBridge = ({ children }: { children: React.ReactNode }) => {
|
||||
const { timezone } = useTimezone()
|
||||
return <TimestampInfoProvider timezone={timezone}>{children}</TimestampInfoProvider>
|
||||
@@ -182,7 +148,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
<NuqsAdapter>
|
||||
<HydrationBoundary state={pageProps.dehydratedState}>
|
||||
<AuthProvider>
|
||||
<FeatureFlagProviderWithOrgContext API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<StudioFeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<ProfileProvider>
|
||||
<TimezoneProvider>
|
||||
<TimestampInfoTimezoneBridge>
|
||||
@@ -247,7 +213,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
</TimestampInfoTimezoneBridge>
|
||||
</TimezoneProvider>
|
||||
</ProfileProvider>
|
||||
</FeatureFlagProviderWithOrgContext>
|
||||
</StudioFeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</HydrationBoundary>
|
||||
</NuqsAdapter>
|
||||
|
||||
@@ -34,13 +34,7 @@ import {
|
||||
type ErrorComponentProps,
|
||||
} from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import {
|
||||
FeatureFlagProvider,
|
||||
getFlags,
|
||||
TelemetryTagManager,
|
||||
ThemeProvider,
|
||||
useThemeSandbox,
|
||||
} from 'common'
|
||||
import { TelemetryTagManager, ThemeProvider, useThemeSandbox } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
@@ -48,15 +42,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { DevToolbar, DevToolbarProvider, DevToolbarTrigger, type ExtraTab } from 'dev-tools'
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ComponentProps,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { lazy, Suspense, useEffect, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { TooltipProvider } from 'ui'
|
||||
import { TimestampInfoProvider } from 'ui-patterns/TimestampInfo'
|
||||
@@ -70,15 +56,15 @@ import { IndirectTaxDeclarationModal } from '@/components/interfaces/App/Indirec
|
||||
import { MonacoThemeProvider } from '@/components/interfaces/App/MonacoThemeProvider'
|
||||
import { RouteValidationWrapper } from '@/components/interfaces/App/RouteValidationWrapper'
|
||||
import { ShellFallback } from '@/components/interfaces/App/ShellFallback'
|
||||
import { StudioFeatureFlagProvider } from '@/components/interfaces/App/StudioFeatureFlagProvider'
|
||||
import { MainScrollContainerProvider } from '@/components/layouts/MainScrollContainerContext'
|
||||
import { BannerStackProvider } from '@/components/ui/BannerStack/BannerStackProvider'
|
||||
import { GlobalErrorBoundaryState } from '@/components/ui/ErrorBoundary/GlobalErrorBoundaryState'
|
||||
import { GlobalShortcuts } from '@/components/ui/GlobalShortcuts/GlobalShortcuts'
|
||||
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { AuthProvider } from '@/lib/auth'
|
||||
import { configureMonacoLoader } from '@/lib/configure-monaco-loader'
|
||||
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
|
||||
import { API_URL, BASE_PATH, IS_PLATFORM } from '@/lib/constants'
|
||||
import { TimezoneProvider, useTimezone } from '@/lib/datetime'
|
||||
import { splitInternalUrl } from '@/lib/internal-url'
|
||||
// Custom adapter instead of `nuqs/adapters/tanstack-router` — the stock one
|
||||
@@ -103,34 +89,6 @@ interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
const FeatureFlagProviderWithOrgContext = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof FeatureFlagProvider>) => {
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM })
|
||||
const cloudProvider = useDefaultProvider()
|
||||
|
||||
const getConfigCatFlags = useCallback(
|
||||
(userEmail?: string) => {
|
||||
const customAttributes: Record<string, string> = {}
|
||||
if (cloudProvider) customAttributes.cloud_provider = cloudProvider
|
||||
if (selectedOrganization?.plan?.id) customAttributes.plan = selectedOrganization.plan.id
|
||||
return getFlags(userEmail, customAttributes)
|
||||
},
|
||||
[cloudProvider, selectedOrganization?.plan?.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<FeatureFlagProvider
|
||||
{...props}
|
||||
getConfigCatFlags={getConfigCatFlags}
|
||||
organizationSlug={selectedOrganization?.slug ?? undefined}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Bridges the user's stored timezone preference into TimestampInfoProvider so
|
||||
// dayjs.tz.setDefault runs app-wide (see @/lib/datetime).
|
||||
const TimestampInfoTimezoneBridge = ({ children }: { children: ReactNode }) => {
|
||||
@@ -371,7 +329,7 @@ function RootComponent() {
|
||||
<ErrorBoundary FallbackComponent={GlobalErrorBoundaryState} onError={errorBoundaryHandler}>
|
||||
<NuqsAdapter>
|
||||
<AuthProvider>
|
||||
<FeatureFlagProviderWithOrgContext API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<StudioFeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<ProfileProvider>
|
||||
<TimezoneProvider>
|
||||
<TimestampInfoTimezoneBridge>
|
||||
@@ -411,7 +369,7 @@ function RootComponent() {
|
||||
</TimestampInfoTimezoneBridge>
|
||||
</TimezoneProvider>
|
||||
</ProfileProvider>
|
||||
</FeatureFlagProviderWithOrgContext>
|
||||
</StudioFeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</NuqsAdapter>
|
||||
<TelemetryTagManager />
|
||||
|
||||
Reference in new issue
Block a user