Skip to content
Open
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
4 changes: 2 additions & 2 deletions frontend/src/queries/schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions frontend/src/queries/schema/schema-general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3766,7 +3766,7 @@ export interface ExperimentApiMetric {
}

export interface ExperimentVariant {
/** Variant key. Exactly one variant in feature_flag_variants must use key 'control' (lowercase, exactly) — that is the baseline used for analysis and the special key the experiment runtime expects. Other variants use keys like 'test', 'variant_a', 'variant_b'. Map natural-language names ('original', 'A', 'baseline') to 'control'. */
/** Variant key. Keys are preserved as provided and are not renamed. The experiment baseline is selected with stats_config.baseline_variant_key; when unset, experiments default to 'control' if present, otherwise the first configured variant. */
key: string
/** Human-readable variant name. */
name?: string
Expand All @@ -3777,7 +3777,7 @@ export interface ExperimentVariant {
}

export interface ExperimentParameters {
/** Experiment variants. If specified, must include a variant with key 'control' (lowercase). Defaults to a 50/50 control/test split when omitted. Minimum 2, maximum 20. */
/** Experiment variants. Defaults to a 50/50 control/test split when omitted. Minimum 2, maximum 20. */
feature_flag_variants?: ExperimentVariant[]
/** Minimum detectable effect as a percentage. Lower values need more users but catch smaller changes. Suggest 20–30% for most experiments. */
minimum_detectable_effect?: number
Expand Down
24 changes: 12 additions & 12 deletions frontend/src/scenes/experiments/ExperimentCodeSnippets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function AndroidSnippet({ flagKey, variant }: SnippetProps): JSX.Element
{`if (PostHog.getFeatureFlag("${flagKey}") == "${variant}") {
// do something
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}`}
</CodeSnippet>
Expand All @@ -43,7 +43,7 @@ export function IOSSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
{`if (PostHogSDK.shared.getFeatureFlag("${flagKey}") as? String == "${variant}") {
// do something
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}`}
</CodeSnippet>
Expand All @@ -60,7 +60,7 @@ export function NodeJSSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
if (experimentFlagValue === '${variant}' ) {
// Do something differently for this user
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}`}
</CodeSnippet>
Expand All @@ -76,7 +76,7 @@ export function JSSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
{`if (posthog.getFeatureFlag('${flagKey}') === '${variant}') {
// Do something differently for this user
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}`}
</CodeSnippet>
Expand Down Expand Up @@ -134,7 +134,7 @@ export function RNSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
{`if (posthog.getFeatureFlag('${flagKey}') === '${variant}') {
// Do something differently for this user
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}`}
</CodeSnippet>
Expand All @@ -149,7 +149,7 @@ export function PHPSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
{`if (PostHog::getFeatureFlag('${flagKey}', 'user distinct id') == '${variant}') {
// Do something differently for this user
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}`}
</CodeSnippet>
Expand All @@ -167,12 +167,12 @@ export function GolangSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
DistinctId: "distinct-id",
})
if err != nil {
// Handle error (e.g. capture error and fallback to default behaviour)
// Handle error (e.g. capture error and fallback to default behavior)
}
if experimentFlagValue == '${variant}' {
// Do something differently for this user
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}`}
</CodeSnippet>
Expand All @@ -192,7 +192,7 @@ export function FlutterSnippet({ flagKey, variant }: SnippetProps): JSX.Element
{`if (${clientSuffix}${flagFunction}('${flagKey}')${variantSuffix}) {
// Do something differently for this user
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}
`}
Expand All @@ -211,7 +211,7 @@ export function RubySnippet({ flagKey, variant }: SnippetProps): JSX.Element {
if experimentFlagValue == '${variant}'
# Do something differently for this user
else
# It's a good idea to let control variant always be the default behaviour,
# It's a good idea to let the baseline/default variant be the default behavior,
# so if something goes wrong with flag evaluation, you don't break your app.
end
`}
Expand All @@ -230,7 +230,7 @@ export function PythonSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
if experiment_flag_value == '${variant}':
# Do something differently for this user
else:
# It's a good idea to let control variant always be the default behaviour,
# It's a good idea to let the baseline/default variant be the default behavior,
# so if something goes wrong with flag evaluation, you don't break your app.
`}
</CodeSnippet>
Expand All @@ -247,7 +247,7 @@ export function JavaSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
if ("${variant}".equals(flagValue)) {
// Do something differently for this user
} else {
// It's a good idea to let control variant always be the default behaviour,
// It's a good idea to let the baseline/default variant be the default behavior,
// so if something goes wrong with flag evaluation, you don't break your app.
}
`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const SelectExistingFeatureFlagModal = ({
<div className="deprecated-space-y-2">
<div className="text-muted mb-2 max-w-xl">
Select an existing multivariate feature flag to use with this experiment. The feature flag must use
multiple variants with <code>'control'</code> as the first.
at least two variants. You can choose which variant is the baseline before saving.
</div>
{filtersSection}
<LemonTable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import '@testing-library/jest-dom'

import { type RenderResult, cleanup, render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'

import { MAX_EXPERIMENT_VARIANTS } from 'lib/constants'

Expand All @@ -25,6 +26,19 @@ jest.mock('scenes/feature-flags/JSONEditorInput', () => ({
),
}))

jest.mock('lib/lemon-ui/LemonSelect', () => ({
LemonSelect: ({ value, options, onChange, disabledReason }: any) => (
<button
aria-label="Baseline variant"
type="button"
disabled={!!disabledReason}
onClick={() => onChange?.((options.find((option: any) => option.value !== value) ?? options[0])?.value)}
>
Select baseline variant
</button>
),
}))

describe('VariantsPanelCreateFeatureFlag', () => {
const mockOnChange = jest.fn()

Expand Down Expand Up @@ -129,6 +143,7 @@ describe('VariantsPanelCreateFeatureFlag', () => {
expect.objectContaining({ key: 'test-2' }),
]),
}),
stats_config: expect.objectContaining({ baseline_variant_key: 'control' }),
})
})

Expand All @@ -138,13 +153,15 @@ describe('VariantsPanelCreateFeatureFlag', () => {
const addButton = screen.getByRole('button', { name: /add variant/i })
await userEvent.click(addButton)

expect(mockOnChange).toHaveBeenCalledWith({
parameters: expect.objectContaining({
feature_flag_variants: expect.arrayContaining([
expect.objectContaining({ rollout_percentage: expect.any(Number) }),
]),
}),
})
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.objectContaining({
feature_flag_variants: expect.arrayContaining([
expect.objectContaining({ rollout_percentage: expect.any(Number) }),
]),
}),
})
)

// Check that percentages sum to 100
const call = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0]
Expand Down Expand Up @@ -238,20 +255,21 @@ describe('VariantsPanelCreateFeatureFlag', () => {

const deleteButtons = container.querySelectorAll('[data-attr^="delete-prop-filter"]')

// Click the first delete button (which is for the second variant, since control has no delete button)
// Click the first delete button, which removes the current baseline.
await userEvent.click(deleteButtons[0] as Element)

expect(mockOnChange).toHaveBeenCalledWith({
parameters: expect.objectContaining({
feature_flag_variants: expect.arrayContaining([
expect.objectContaining({ key: 'control' }),
expect.objectContaining({ key: 'test' }),
expect.objectContaining({ key: 'test-2' }),
]),
}),
stats_config: expect.objectContaining({ baseline_variant_key: 'test' }),
})
})

it('does not show delete button for control variant', () => {
it('allows removing the current baseline when another variant remains', () => {
const experimentWithThreeVariants = {
...defaultExperiment,
parameters: {
Expand All @@ -268,11 +286,73 @@ describe('VariantsPanelCreateFeatureFlag', () => {
)

const rows = container.querySelectorAll('tbody tr')
const controlRow = rows[0] // First row (control variant)
const baselineRow = rows[0]

expect(baselineRow.querySelector('[data-attr^="delete-prop-filter"]')).toBeInTheDocument()
})

it('updates the baseline when selecting a different variant', async () => {
renderComponent(defaultExperiment)

// Control variant should not have a delete button
const deleteButton = controlRow.querySelector('[data-attr^="delete-prop-filter"]')
expect(deleteButton).not.toBeInTheDocument()
await userEvent.click(screen.getByLabelText('Baseline variant'))

expect(mockOnChange).toHaveBeenCalledWith({
stats_config: expect.objectContaining({ baseline_variant_key: 'test' }),
})
})

it('keeps an explicit baseline variant when editing another variant key', async () => {
const experimentWithTestBaseline = {
...defaultExperiment,
stats_config: { baseline_variant_key: 'test' },
}

renderComponent(experimentWithTestBaseline)

const variantInputs = screen.getAllByPlaceholderText(/example-variant/)
await userEvent.clear(variantInputs[0])
await userEvent.type(variantInputs[0], 'baseline')

const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0]
expect(lastCall.stats_config).toEqual(expect.objectContaining({ baseline_variant_key: 'test' }))
})

it('keeps the baseline attached when clearing and retyping its variant key', async () => {
const ControlledPanel = (): JSX.Element => {
const [experiment, setExperiment] = useState<Experiment>({
...defaultExperiment,
stats_config: { baseline_variant_key: 'control' },
})

return (
<VariantsPanelCreateFeatureFlag
experiment={experiment}
onChange={(updates) => {
mockOnChange(updates)
setExperiment((current) => ({
...current,
...updates,
parameters: updates.parameters
? { ...current.parameters, ...updates.parameters }
: current.parameters,
stats_config: updates.stats_config
? { ...current.stats_config, ...updates.stats_config }
: current.stats_config,
}))
}}
/>
)
}

render(<ControlledPanel />)

const variantInputs = screen.getAllByPlaceholderText(/example-variant/)
await userEvent.clear(variantInputs[0])
await userEvent.type(variantInputs[0], 'baseline')

const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0]
expect(lastCall.parameters.feature_flag_variants[0].key).toBe('baseline')
expect(lastCall.stats_config).toEqual(expect.objectContaining({ baseline_variant_key: 'baseline' }))
})
})

Expand All @@ -293,14 +373,16 @@ describe('VariantsPanelCreateFeatureFlag', () => {
const balanceButton = screen.getByTestId('distribute-variants-equally')
await userEvent.click(balanceButton)

expect(mockOnChange).toHaveBeenCalledWith({
parameters: expect.objectContaining({
feature_flag_variants: [
expect.objectContaining({ key: 'control', rollout_percentage: 50 }),
expect.objectContaining({ key: 'test', rollout_percentage: 50 }),
],
}),
})
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.objectContaining({
feature_flag_variants: [
expect.objectContaining({ key: 'control', rollout_percentage: 50 }),
expect.objectContaining({ key: 'test', rollout_percentage: 50 }),
],
}),
})
)
})

it.each([
Expand Down
Loading