feat(studio): support ConfigCat targeting by project ref

This commit is contained in:
Alaister Young committed 2026-10-03 12:57:11 -07:00
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>
)
}
+6 -40
View File
@@ -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>
+6 -48
View File
@@ -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 />