Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
isInDataApiRevokeTreatment,
useDataApiRevokeOnCreateDefaultEnabled,
useTrackDefaultPrivilegesExposure,
} from '../useDataApiRevokeOnCreateDefault'
Expand All @@ -25,6 +26,33 @@ vi.mock('@/lib/telemetry/track', () => ({
useTrack: vi.fn(),
}))

describe('isInDataApiRevokeTreatment', () => {
it('returns true for boolean true (current rollout shape)', () => {
expect(isInDataApiRevokeTreatment(true)).toBe(true)
})

it("returns true for the 'test' variant (future multivariate shape)", () => {
expect(isInDataApiRevokeTreatment('test')).toBe(true)
})

it('returns false for boolean false', () => {
expect(isInDataApiRevokeTreatment(false)).toBe(false)
})

it("returns false for the 'control' variant", () => {
expect(isInDataApiRevokeTreatment('control')).toBe(false)
})

it('returns false for undefined (flag not resolved)', () => {
expect(isInDataApiRevokeTreatment(undefined)).toBe(false)
})

it('returns false for unrelated string values', () => {
expect(isInDataApiRevokeTreatment('something-else')).toBe(false)
expect(isInDataApiRevokeTreatment('')).toBe(false)
})
})

describe('useDataApiRevokeOnCreateDefaultEnabled', () => {
afterEach(() => {
vi.restoreAllMocks()
Expand All @@ -49,6 +77,18 @@ describe('useDataApiRevokeOnCreateDefaultEnabled', () => {
expect(result.current).toBe(true)
})

it("returns true when the PostHog flag is the 'test' variant string", () => {
vi.mocked(usePHFlag).mockReturnValue('test')
const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
expect(result.current).toBe(true)
})

it("returns false when the PostHog flag is the 'control' variant string", () => {
vi.mocked(usePHFlag).mockReturnValue('control')
const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
expect(result.current).toBe(false)
})

it('returns false in test env regardless of flag value', () => {
vi.mocked(constants, { partial: true }).IS_TEST_ENV = true
vi.mocked(usePHFlag).mockReturnValue(true)
Expand Down Expand Up @@ -252,4 +292,53 @@ describe('useTrackDefaultPrivilegesExposure', () => {
undefined
)
})

// The next two tests cover the future multivariate flag shape (GROWTH-877).
// Today the flag returns boolean true/false; post-migration it returns the
// variant string. The convergence gate must derive the expected default from
// the variant, not from `!flag` directly — `!'control'` is false (truthy
// string negation), which would have set the wrong expected default and
// either skipped or mis-fired the exposure for control-arm users.

it("fires for the 'test' variant with the correct convergence default (treatment)", () => {
vi.mocked(usePHFlag).mockReturnValue('test')
renderHook(() =>
useTrackDefaultPrivilegesExposure({
surface: 'main',
dataApiDefaultPrivileges: false, // expected default for treatment
hasUserModified: false,
})
)
expect(track).toHaveBeenCalledTimes(1)
expect(track).toHaveBeenCalledWith(
'project_creation_default_privileges_exposed',
{
surface: 'main',
dataApiDefaultPrivileges: false,
dataApiRevokeOnCreateDefaultEnabled: 'test',
},
undefined
)
})

it("fires for the 'control' variant with the correct convergence default (legacy)", () => {
vi.mocked(usePHFlag).mockReturnValue('control')
renderHook(() =>
useTrackDefaultPrivilegesExposure({
surface: 'main',
dataApiDefaultPrivileges: true, // expected default for non-treatment
hasUserModified: false,
})
)
expect(track).toHaveBeenCalledTimes(1)
expect(track).toHaveBeenCalledWith(
'project_creation_default_privileges_exposed',
{
surface: 'main',
dataApiDefaultPrivileges: true,
dataApiRevokeOnCreateDefaultEnabled: 'control',
},
undefined
)
})
})
29 changes: 25 additions & 4 deletions apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,34 @@ import { usePHFlag } from '../ui/useFlag'
import { IS_TEST_ENV } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'

/**
* Returns true iff the user is assigned to the treatment arm of the
* dataApiRevokeOnCreateDefault experiment. Shape-agnostic across the current
* boolean rollout config and a future multivariate experiment with named
* variants. See GROWTH-877 for the migration plan.
*
* Accepts:
* - `true` → treatment (current boolean shape)
* - `'test'` → treatment (future multivariate shape)
* - anything else (`false`, `'control'`, `null`, `undefined`) → not treatment
*
* Use this everywhere the flag's value is read so the PostHog config can
* migrate to multivariate without a coordinated frontend deploy.
*/
export const isInDataApiRevokeTreatment = (flag: boolean | string | undefined): boolean => {
if (flag === true) return true
if (flag === 'test') return true
return false
}

/**
* Controls the default state of the "Automatically expose new tables"
* checkbox at project creation. When the flag is on, the checkbox defaults
* to unchecked (i.e. revoke SQL runs). When off/absent, the checkbox defaults
* to checked (current behaviour — default grants remain).
*/
export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => {
const flag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
const flag = usePHFlag<boolean | string>('dataApiRevokeOnCreateDefault')

// Preserve current behaviour (default grants remain) in tests so existing
// E2E flows don't change silently. Tests that need the revoke-default path
Expand All @@ -20,7 +40,7 @@ export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => {
return false
}

return !!flag
return isInDataApiRevokeTreatment(flag)
}

type DefaultPrivilegesExposureOptions =
Expand Down Expand Up @@ -48,7 +68,7 @@ type DefaultPrivilegesExposureOptions =
*/
export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExposureOptions) => {
const track = useTrack()
const flag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
const flag = usePHFlag<boolean | string>('dataApiRevokeOnCreateDefault')
const hasTracked = useRef(false)

const { surface, dataApiDefaultPrivileges, hasUserModified } = options
Expand All @@ -59,7 +79,8 @@ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExpo
if (flag === undefined) return
if (surface === 'vercel' && !orgSlug) return
// Gate on form-flag convergence unless the user explicitly dirtied the field.
if (!hasUserModified && dataApiDefaultPrivileges !== !flag) return
const expectedDefault = !isInDataApiRevokeTreatment(flag)
if (!hasUserModified && dataApiDefaultPrivileges !== expectedDefault) return
hasTracked.current = true
track(
'project_creation_default_privileges_exposed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useProjectCreateMutation } from '@/data/projects/project-create-mutation'
import {
isInDataApiRevokeTreatment,
useDataApiRevokeOnCreateDefaultEnabled,
useTrackDefaultPrivilegesExposure,
} from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
Expand Down Expand Up @@ -82,7 +83,9 @@ const CreateProject = () => {
const track = useTrack()
const snapshot = useIntegrationInstallationSnapshot()
const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled()
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean | string>(
'dataApiRevokeOnCreateDefault'
)
const [dataApiDefaultPrivileges, setDataApiDefaultPrivileges] = useState(
!isDataApiRevokeOnCreateDefault
)
Expand All @@ -91,7 +94,7 @@ const CreateProject = () => {
useEffect(() => {
if (dataApiRevokeOnCreateDefaultFlag === undefined) return
if (hasUserModifiedDataApiDefaultPrivileges.current) return
setDataApiDefaultPrivileges(!dataApiRevokeOnCreateDefaultFlag)
setDataApiDefaultPrivileges(!isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag))
}, [dataApiRevokeOnCreateDefaultFlag])

const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams()
Expand Down
22 changes: 16 additions & 6 deletions apps/studio/pages/new/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ import {
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useDataApiRevokeOnCreateDefaultEnabled } from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
import {
isInDataApiRevokeTreatment,
useDataApiRevokeOnCreateDefaultEnabled,
} from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
Expand Down Expand Up @@ -108,8 +111,11 @@ const Wizard: NextPageWithLayout = () => {

// Read the raw flag for telemetry — coerce-undefined-to-false would record false for
// users whose flags haven't loaded yet. The raw value preserves undefined (omitted from
// PostHog) so we only record true/false when the flag is resolved.
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
// PostHog) so we only record an actual value (boolean true/false, or a variant string
// like 'test'/'control' post-multivariate migration) once the flag has resolved.
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean | string>(
'dataApiRevokeOnCreateDefault'
)
const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled()

const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '')
Expand Down Expand Up @@ -174,9 +180,13 @@ const Wizard: NextPageWithLayout = () => {
useEffect(() => {
if (dataApiRevokeOnCreateDefaultFlag === undefined) return
if (isDataApiDefaultPrivilegesDirty) return
setValue('dataApiDefaultPrivileges', !dataApiRevokeOnCreateDefaultFlag, {
shouldDirty: false,
})
setValue(
'dataApiDefaultPrivileges',
!isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag),
{
shouldDirty: false,
}
)
}, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue])

// [Charis] Since the form is updated in a useEffect, there is an edge case
Expand Down
18 changes: 12 additions & 6 deletions packages/common/telemetry-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,13 @@ export interface ProjectCreationDefaultPrivilegesExposedEvent {
dataApiDefaultPrivileges: boolean
/**
* Raw value of the dataApiRevokeOnCreateDefault PostHog flag at exposure time.
* true = revoke cohort (checkbox defaulted to unchecked)
* false = control cohort (checkbox defaulted to checked)
* Accepts boolean (current rollout shape) or string (post-multivariate-migration
* variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan.
* true | 'test' = revoke cohort (checkbox defaulted to unchecked)
* false = outside the rollout (checkbox defaulted to checked)
* 'control' = in-experiment control arm (checkbox defaulted to checked)
*/
dataApiRevokeOnCreateDefaultEnabled: boolean
dataApiRevokeOnCreateDefaultEnabled: boolean | string
}
groups: Omit<TelemetryGroups, 'project'>
}
Expand Down Expand Up @@ -381,14 +384,17 @@ export interface ProjectCreationSimpleVersionSubmittedEvent {
*/
dataApiDefaultPrivilegesGranted?: boolean
/**
* Whether the dataApiRevokeOnCreateDefault PostHog flag was enabled for this user.
* Raw value of the dataApiRevokeOnCreateDefault PostHog flag at submission time.
* Controls only the default checkbox state of "Automatically expose new tables and functions"
* at project creation. Tracking it lets us correlate flag cohort with user choice.
* true = user is in the staged rollout cohort (checkbox defaulted to unchecked)
* Accepts boolean (current rollout shape) or string (post-multivariate-migration
* variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan.
* true | 'test' = user is in the treatment arm (checkbox defaulted to unchecked)
* false = user is outside the rollout (checkbox defaulted to checked)
* 'control' = in-experiment control arm (checkbox defaulted to checked)
* omitted = PostHog flags had not loaded at the time of project creation
*/
dataApiRevokeOnCreateDefaultEnabled?: boolean
dataApiRevokeOnCreateDefaultEnabled?: boolean | string
}
groups: TelemetryGroups
}
Expand Down
Loading