From 4ea568c0ea3754583b271ad4c32cf2880df09681 Mon Sep 17 00:00:00 2001 From: Alaister Young <10985857+alaister@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:57:11 -0700 Subject: [PATCH] feat(studio): support ConfigCat targeting by project ref --- .../App/StudioFeatureFlagProvider.test.tsx | 148 ++++++++++++++++++ .../App/StudioFeatureFlagProvider.tsx | 35 +++++ apps/studio/pages/_app.tsx | 46 +----- apps/studio/routes/__root.tsx | 54 +------ 4 files changed, 195 insertions(+), 88 deletions(-) create mode 100644 apps/studio/components/interfaces/App/StudioFeatureFlagProvider.test.tsx create mode 100644 apps/studio/components/interfaces/App/StudioFeatureFlagProvider.tsx diff --git a/apps/studio/components/interfaces/App/StudioFeatureFlagProvider.test.tsx b/apps/studio/components/interfaces/App/StudioFeatureFlagProvider.test.tsx new file mode 100644 index 00000000000..6425e8c4452 --- /dev/null +++ b/apps/studio/components/interfaces/App/StudioFeatureFlagProvider.test.tsx @@ -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()), + getFlags, +})) + +vi.mock('@/lib/constants', async (importOriginal) => ({ + ...(await importOriginal()), + 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
{isEnabled ? 'Enabled' : 'Disabled'}
+} + +function TestProvider() { + return ( + Promise.resolve(SESSION), + }} + > + + + + + ) +} + +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([ + createMockOrganizationResponse({ slug: 'test-org', plan: { id: 'pro', name: 'Pro' } }), + ]), + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: ({ params }) => + HttpResponse.json({ + 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(, { 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(, { 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() + }) +}) diff --git a/apps/studio/components/interfaces/App/StudioFeatureFlagProvider.tsx b/apps/studio/components/interfaces/App/StudioFeatureFlagProvider.tsx new file mode 100644 index 00000000000..9dd3adb2681 --- /dev/null +++ b/apps/studio/components/interfaces/App/StudioFeatureFlagProvider.tsx @@ -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) => { + const { ref: projectRef } = useParams() + const { data: selectedOrganization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM }) + const cloudProvider = useDefaultProvider() + + const getConfigCatFlags = useCallback( + (userEmail?: string) => { + const customAttributes: Record = {} + 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 ( + + {children} + + ) +} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 576baa72ac1..e8318f33118 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -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) => { - const { data: selectedOrganization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM }) - const cloudProvider = useDefaultProvider() - - const getConfigCatFlags = useCallback( - (userEmail?: string) => { - const customAttributes: Record = {} - if (cloudProvider) customAttributes.cloud_provider = cloudProvider - if (selectedOrganization?.plan?.id) customAttributes.plan = selectedOrganization.plan.id - return getFlags(userEmail, customAttributes) - }, - [cloudProvider, selectedOrganization?.plan?.id] - ) - - return ( - - {children} - - ) -} - const TimestampInfoTimezoneBridge = ({ children }: { children: React.ReactNode }) => { const { timezone } = useTimezone() return {children} @@ -182,7 +148,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - + @@ -247,7 +213,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - + diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 26bd1707999..efb10eb3a21 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -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) => { - const { data: selectedOrganization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM }) - const cloudProvider = useDefaultProvider() - - const getConfigCatFlags = useCallback( - (userEmail?: string) => { - const customAttributes: Record = {} - if (cloudProvider) customAttributes.cloud_provider = cloudProvider - if (selectedOrganization?.plan?.id) customAttributes.plan = selectedOrganization.plan.id - return getFlags(userEmail, customAttributes) - }, - [cloudProvider, selectedOrganization?.plan?.id] - ) - - return ( - - {children} - - ) -} - // 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() { - + @@ -411,7 +369,7 @@ function RootComponent() { - +