Files
posthog/frontend/src/scenes/experiments/ExperimentForm.tsx
2025-09-08 16:47:35 +01:00

579 lines
27 KiB
TypeScript

import { useActions, useValues } from 'kea'
import { Form, Group } from 'kea-forms'
import { useState } from 'react'
import { IconPlusSmall, IconToggle, IconTrash } from '@posthog/icons'
import {
LemonBanner,
LemonCheckbox,
LemonDivider,
LemonInput,
LemonModal,
LemonTable,
LemonTextArea,
Link,
Tooltip,
} from '@posthog/lemon-ui'
import { ExperimentVariantNumber } from 'lib/components/SeriesGlyph'
import { MAX_EXPERIMENT_VARIANTS } from 'lib/constants'
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { GroupsAccessStatus, groupsAccessLogic } from 'lib/introductions/groupsAccessLogic'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonRadio } from 'lib/lemon-ui/LemonRadio'
import { LemonSelect } from 'lib/lemon-ui/LemonSelect'
import { IconOpenInNew } from 'lib/lemon-ui/icons'
import { capitalizeFirstLetter } from 'lib/utils'
import { cn } from 'lib/utils/css-classes'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { experimentsLogic } from 'scenes/experiments/experimentsLogic'
import { urls } from 'scenes/urls'
import { SceneContent } from '~/layout/scenes/components/SceneContent'
import { SceneDivider } from '~/layout/scenes/components/SceneDivider'
import { SceneSection } from '~/layout/scenes/components/SceneSection'
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
import { FeatureFlagType } from '~/types'
import { experimentLogic } from './experimentLogic'
import { featureFlagEligibleForExperiment } from './utils'
const ExperimentFormFields = (): JSX.Element => {
const { formMode, experiment, groupTypes, aggregationLabel, hasPrimaryMetricSet, validExistingFeatureFlag } =
useValues(experimentLogic)
const { addVariant, removeVariant, setExperiment, submitExperiment, setExperimentType, validateFeatureFlag } =
useActions(experimentLogic)
const { webExperimentsAvailable, unavailableFeatureFlagKeys } = useValues(experimentsLogic)
const { groupsAccessStatus } = useValues(groupsAccessLogic)
const newSceneLayout = useFeatureFlag('NEW_SCENE_LAYOUT')
const { reportExperimentFeatureFlagModalOpened, reportExperimentFeatureFlagSelected } = useActions(eventUsageLogic)
const [showFeatureFlagSelector, setShowFeatureFlagSelector] = useState(false)
return (
<SceneContent forceNewSpacing>
<SceneTitleSection
name={experiment.name}
description={null}
resourceType={{
type: 'experiment',
}}
canEdit
onNameChange={(name) => {
setExperiment({ name })
}}
forceEdit={formMode === 'create'}
/>
<SceneDivider />
{hasPrimaryMetricSet && formMode !== 'duplicate' && (
<LemonBanner type="info" className="my-4">
Fill out the details below to create your experiment based off of the insight.
</LemonBanner>
)}
{formMode === 'duplicate' && (
<LemonBanner type="info" className="my-4">
We'll copy all settings, including metrics and exposure configuration, from the&nbsp;
<Link target="_blank" className="font-semibold items-center" to={urls.experiment(experiment.id)}>
original experiment
<IconOpenInNew fontSize="18" />
</Link>
.
</LemonBanner>
)}
{!newSceneLayout && (
<LemonField name="name" label="Name" className="max-w-120">
<LemonInput
placeholder="Pricing page conversion"
data-attr="experiment-name"
onBlur={() => {
// bail if feature flag key is already set
if (experiment.feature_flag_key) {
return
}
setExperiment({
feature_flag_key: generateFeatureFlagKey(experiment.name, unavailableFeatureFlagKeys),
})
}}
/>
</LemonField>
)}
<SceneSection
title="Feature flag key"
description="Each experiment is backed by a feature flag."
hideTitleAndDescription={!newSceneLayout}
>
<LemonField
name="feature_flag_key"
label={!newSceneLayout ? 'Feature flag key' : null}
className="max-w-120"
help={
<div className="flex items-center justify-between">
{!newSceneLayout && <span>Each experiment is backed by a feature flag.</span>}
<LemonButton
type="secondary"
size="xsmall"
onClick={() => {
reportExperimentFeatureFlagModalOpened()
setShowFeatureFlagSelector(true)
}}
>
<IconToggle className="mr-1" />
Link to existing feature flag
</LemonButton>
</div>
}
>
<LemonInput placeholder="pricing-page-conversion" data-attr="experiment-feature-flag-key" />
</LemonField>
</SceneSection>
<SceneDivider />
<SceneSection
title="Hypothesis / Description"
description="Add your hypothesis for this test"
hideTitleAndDescription={!newSceneLayout}
>
<LemonField name="description" label={!newSceneLayout ? 'Description' : null} className="max-w-120">
<LemonTextArea
placeholder="The goal of this experiment is ..."
data-attr="experiment-description"
/>
</LemonField>
</SceneSection>
<SceneDivider />
<SelectExistingFeatureFlagModal
isOpen={showFeatureFlagSelector}
onClose={() => setShowFeatureFlagSelector(false)}
onSelect={(flag) => {
reportExperimentFeatureFlagSelected(flag.key)
setExperiment({
feature_flag_key: flag.key,
parameters: {
...experiment.parameters,
feature_flag_variants: flag.filters?.multivariate?.variants || [],
},
})
validateFeatureFlag(flag.key)
setShowFeatureFlagSelector(false)
}}
/>
{webExperimentsAvailable && (
<>
<SceneSection
title="Experiment type"
description="Select your experiment setup, this cannot be changed once saved."
hideTitleAndDescription={!newSceneLayout}
className={cn(!newSceneLayout && 'gap-y-0 mt-6')}
>
{!newSceneLayout && (
<>
<h3 className="mb-1">Experiment type</h3>
<div className="text-xs text-secondary font-medium tracking-normal">
Select your experiment setup, this cannot be changed once saved.
</div>
</>
)}
{!newSceneLayout && <LemonDivider />}
<LemonRadio
value={experiment.type}
className="flex flex-col gap-2"
onChange={(type) => {
setExperimentType(type)
}}
options={[
{
value: 'product',
description: (
<div className="text-xs text-secondary">
Use custom code to manage how variants modify your product.
</div>
),
label: 'Product experiment',
},
{
value: 'web',
label: 'No-code web experiment',
description: (
<div className="text-xs text-secondary">
Define variants on your website using the PostHog toolbar, no coding
required.
</div>
),
},
]}
/>
</SceneSection>
<SceneDivider />
</>
)}
{groupsAccessStatus === GroupsAccessStatus.AlreadyUsing && (
<>
<SceneSection
title="Participant type"
description="Determines on what level you want to aggregate metrics. You can change this later, but flag values for users will change so you need to reset the experiment for accurate results."
hideTitleAndDescription={!newSceneLayout}
className={cn(!newSceneLayout && 'gap-y-0 mt-6')}
>
{!newSceneLayout && (
<>
<h3>Participant type</h3>
<div className="text-xs text-secondary max-w-150">
Determines on what level you want to aggregate metrics. You can change this later,
but flag values for users will change so you need to reset the experiment for
accurate results.
</div>
</>
)}
{!newSceneLayout && <LemonDivider />}
<LemonRadio
value={
experiment.parameters.aggregation_group_type_index != undefined
? experiment.parameters.aggregation_group_type_index
: -1
}
onChange={(rawGroupTypeIndex) => {
const groupTypeIndex = rawGroupTypeIndex !== -1 ? rawGroupTypeIndex : undefined
setExperiment({
parameters: {
...experiment.parameters,
aggregation_group_type_index: groupTypeIndex ?? undefined,
},
})
}}
options={[
{ value: -1, label: 'Persons' },
...Array.from(groupTypes.values()).map((groupType) => ({
value: groupType.group_type_index,
label: capitalizeFirstLetter(aggregationLabel(groupType.group_type_index).plural),
})),
]}
/>
</SceneSection>
<SceneDivider />
</>
)}
{validExistingFeatureFlag && (
<>
<SceneSection
title="Variants"
description="Existing feature flag configuration will be applied to the experiment."
hideTitleAndDescription={!newSceneLayout}
className={cn(!newSceneLayout && 'gap-y-0 mt-6')}
>
{!newSceneLayout && (
<>
<h3 className="mb-1">Variants</h3>
<LemonDivider />
</>
)}
<LemonBanner type="info">
<div className="flex items-center">
<div>Existing feature flag configuration will be applied to the experiment.</div>
<Link
to={urls.featureFlag(validExistingFeatureFlag.id as number)}
target="_blank"
className="flex items-center"
>
<IconOpenInNew className="ml-1" />
</Link>
</div>
</LemonBanner>
</SceneSection>
<SceneDivider />
</>
)}
{!validExistingFeatureFlag && (
<>
<SceneSection
title="Variants"
description={
<>Add up to {MAX_EXPERIMENT_VARIANTS - 1} variants to test against your control.</>
}
hideTitleAndDescription={!newSceneLayout}
className={cn(!newSceneLayout && 'gap-y-0 mt-6')}
>
{!newSceneLayout && (
<>
<h3 className="mb-1">Variants</h3>
<div className="text-xs text-secondary">
Add up to {MAX_EXPERIMENT_VARIANTS - 1} variants to test against your control.
</div>
</>
)}
{!newSceneLayout && <LemonDivider />}
<div className="grid grid-cols-2 gap-4 max-w-160">
<div className="max-w-60">
<h3 className={cn(newSceneLayout && 'text-sm')}>Control</h3>
<div className="flex items-center">
<Group key={0} name={['parameters', 'feature_flag_variants', 0]}>
<ExperimentVariantNumber index={0} className="h-7 w-7 text-base" />
<LemonField name="key" className="ml-2 flex-grow">
<LemonInput
disabled
data-attr="experiment-variant-key"
data-key-index={0}
className="ph-ignore-input"
fullWidth
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</LemonField>
</Group>
</div>
<div className="text-secondary text-xs mt-2">
Included automatically, cannot be edited or removed
</div>
</div>
<div className="max-w-100">
<h3 className={cn(newSceneLayout && 'text-sm')}>Test(s)</h3>
{experiment.parameters.feature_flag_variants?.map((_, index) => {
if (index === 0) {
return null
}
return (
<Group key={index} name={['parameters', 'feature_flag_variants', index]}>
<div
key={`variant-${index}`}
className={`flex items-center deprecated-space-x-2 ${
index > 1 && 'mt-2'
}`}
>
<ExperimentVariantNumber index={index} className="h-7 w-7 text-base" />
<LemonField name="key" className="flex-grow">
<LemonInput
data-attr="experiment-variant-key"
data-key-index={index.toString()}
className="ph-ignore-input"
fullWidth
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</LemonField>
<div className={`${index === 1 && 'pr-9'}`}>
{index !== 1 && (
<Tooltip title="Delete this variant" placement="top-start">
<LemonButton
size="small"
icon={<IconTrash />}
onClick={() => removeVariant(index)}
/>
</Tooltip>
)}
</div>
</div>
</Group>
)
})}
<div className="text-secondary text-xs ml-9 mr-20 mt-2">
Alphanumeric, hyphens and underscores only
</div>
{(experiment.parameters.feature_flag_variants.length ?? 0) <
MAX_EXPERIMENT_VARIANTS && (
<LemonButton
className="ml-9 mt-2"
type="secondary"
onClick={() => addVariant()}
icon={<IconPlusSmall />}
data-attr="add-test-variant"
>
Add test variant
</LemonButton>
)}
</div>
</div>
</SceneSection>
<SceneDivider />
<div className={cn('mt-6 pb-2 max-w-150', newSceneLayout && 'mt-0 pb-0')}>
<LemonField name="parameters.ensure_experience_continuity">
{({ value, onChange }) => (
<label className="border rounded p-4 group" htmlFor="continuity-checkbox">
<LemonCheckbox
id="continuity-checkbox"
label="Persist flag across authentication steps"
onChange={() => onChange(!value)}
fullWidth
checked={value}
/>
<div className="text-secondary text-sm pl-6">
If your feature flag is evaluated for anonymous users, use this option to ensure
the flag value remains consistent after the user logs in. Depending on your
setup, this option may not always be appropriate. Note that this feature
requires creating profiles for anonymous users.{' '}
<Link
to="https://posthog.com/docs/feature-flags/creating-feature-flags#persisting-feature-flags-across-authentication-steps"
target="_blank"
>
Learn more
</Link>
</div>
</label>
)}
</LemonField>
</div>
</>
)}
<LemonButton
className={cn('w-fit', !newSceneLayout && 'mt-2')}
type="primary"
data-attr="save-experiment"
onClick={() => submitExperiment()}
>
Save as draft
</LemonButton>
</SceneContent>
)
}
export const HoldoutSelector = (): JSX.Element => {
const { experiment, holdouts } = useValues(experimentLogic)
const { setExperiment } = useActions(experimentLogic)
const holdoutOptions = holdouts.map((holdout) => ({
value: holdout.id,
label: holdout.name,
}))
holdoutOptions.unshift({ value: null, label: 'No holdout' })
return (
<div className="mt-4 mb-8">
<LemonSelect
options={holdoutOptions}
value={experiment.holdout_id || null}
onChange={(value) => {
setExperiment({
...experiment,
holdout_id: value,
})
}}
data-attr="experiment-holdout-selector"
/>
</div>
)
}
export function ExperimentForm(): JSX.Element {
const { props } = useValues(experimentLogic)
return (
<div>
<Form
id="experiment-step"
logic={experimentLogic}
formKey="experiment"
props={props}
enableFormOnSubmit
className="deprecated-space-y-6 experiment-form"
>
<ExperimentFormFields />
</Form>
</div>
)
}
const generateFeatureFlagKey = (name: string, unavailableFeatureFlagKeys: Set<string>): string => {
const baseKey = name
.toLowerCase()
.replace(/[^A-Za-z0-9-_]+/g, '-')
.replace(/-+$/, '')
.replace(/^-+/, '')
let key = baseKey
let counter = 1
while (unavailableFeatureFlagKeys.has(key)) {
key = `${baseKey}-${counter}`
counter++
}
return key
}
const SelectExistingFeatureFlagModal = ({
isOpen,
onClose,
onSelect,
}: {
isOpen: boolean
onClose: () => void
onSelect: (flag: FeatureFlagType) => void
}): JSX.Element => {
const { featureFlags } = useValues(experimentsLogic)
return (
<LemonModal isOpen={isOpen} onClose={onClose} title="Choose an existing feature flag">
<div className="deprecated-space-y-2">
<div className="text-muted mb-2 max-w-xl">
Select an existing feature flag to use with this experiment. The feature flag must use multiple
variants with <code>'control'</code> as the first, and not be associated with an existing
experiment.
</div>
<LemonTable
dataSource={featureFlags.results}
useURLForSorting={false}
columns={[
{
title: 'Key',
dataIndex: 'key',
sorter: (a, b) => (a.key || '').localeCompare(b.key || ''),
render: (key, flag) => (
<div className="flex items-center">
<div className="font-semibold">{key}</div>
<Link
to={urls.featureFlag(flag.id as number)}
target="_blank"
className="flex items-center"
>
<IconOpenInNew className="ml-1" />
</Link>
</div>
),
},
{
title: 'Name',
dataIndex: 'name',
sorter: (a, b) => (a.name || '').localeCompare(b.name || ''),
},
{
title: null,
render: function RenderActions(_, flag) {
let disabledReason: string | undefined = undefined
try {
featureFlagEligibleForExperiment(flag)
} catch (error) {
disabledReason = (error as Error).message
}
return (
<LemonButton
size="xsmall"
type="primary"
disabledReason={disabledReason}
onClick={() => {
onSelect(flag)
onClose()
}}
>
Select
</LemonButton>
)
},
},
]}
/>
</div>
</LemonModal>
)
}