mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
fix(create experiments): fixes and validations (#40083)
This commit is contained in:
@@ -18,7 +18,6 @@ import { SceneSection } from '~/layout/scenes/components/SceneSection'
|
||||
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
|
||||
import type { Experiment } from '~/types'
|
||||
|
||||
import { ExperimentTypePanel } from './ExperimentTypePanel'
|
||||
import { ExposureCriteriaPanel } from './ExposureCriteriaPanel'
|
||||
import { ExposureCriteriaPanelHeader } from './ExposureCriteriaPanelHeader'
|
||||
import { MetricsPanel, MetricsPanelHeader } from './MetricsPanel'
|
||||
@@ -38,21 +37,14 @@ type CreateExperimentProps = Partial<{
|
||||
draftExperiment: Experiment
|
||||
}>
|
||||
|
||||
/**
|
||||
* temporary setup. We may want to put this behind a feature flag for testing.
|
||||
*/
|
||||
const SHOW_EXPERIMENT_TYPE_PANEL = false
|
||||
const SHOW_TARGETING_PANEL = false
|
||||
|
||||
export const CreateExperiment = ({ draftExperiment }: CreateExperimentProps): JSX.Element => {
|
||||
const { HogfettiComponent } = useHogfetti({ count: 100, duration: 3000 })
|
||||
|
||||
const { experiment, experimentErrors, sharedMetrics } = useValues(
|
||||
createExperimentLogic({ experiment: draftExperiment })
|
||||
)
|
||||
const { setExperimentValue, setExperiment, setSharedMetrics } = useActions(
|
||||
createExperimentLogic({ experiment: draftExperiment })
|
||||
)
|
||||
const { setExperimentValue, setExperiment, setSharedMetrics, setExposureCriteria, setFeatureFlagConfig } =
|
||||
useActions(createExperimentLogic({ experiment: draftExperiment }))
|
||||
|
||||
const [selectedPanel, setSelectedPanel] = useState<string | null>(null)
|
||||
|
||||
@@ -121,59 +113,16 @@ export const CreateExperiment = ({ draftExperiment }: CreateExperimentProps): JS
|
||||
key: 'experiment-exposure',
|
||||
header: <ExposureCriteriaPanelHeader experiment={experiment} />,
|
||||
content: (
|
||||
<ExposureCriteriaPanel
|
||||
experiment={experiment}
|
||||
onChange={(exposureCriteria) => exposureCriteria}
|
||||
/>
|
||||
<ExposureCriteriaPanel experiment={experiment} onChange={setExposureCriteria} />
|
||||
),
|
||||
},
|
||||
...(SHOW_EXPERIMENT_TYPE_PANEL
|
||||
? [
|
||||
{
|
||||
key: 'experiment-type',
|
||||
header: 'Experiment type',
|
||||
content: (
|
||||
<ExperimentTypePanel
|
||||
experiment={experiment}
|
||||
setExperimentType={(type) => setExperimentValue('type', type)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'experiment-variants',
|
||||
header: <VariantsPanelHeader experiment={experiment} />,
|
||||
content: (
|
||||
<VariantsPanel
|
||||
experiment={experiment}
|
||||
updateFeatureFlag={(updates) => {
|
||||
if (updates.feature_flag_key !== undefined) {
|
||||
setExperimentValue('feature_flag_key', updates.feature_flag_key)
|
||||
}
|
||||
if (updates.parameters) {
|
||||
setExperimentValue('parameters', {
|
||||
...experiment.parameters,
|
||||
...updates.parameters,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<VariantsPanel experiment={experiment} updateFeatureFlag={setFeatureFlagConfig} />
|
||||
),
|
||||
},
|
||||
...(SHOW_TARGETING_PANEL
|
||||
? [
|
||||
{
|
||||
key: 'experiment-targeting',
|
||||
header: 'Targeting',
|
||||
content: (
|
||||
<div className="p-4">
|
||||
<span>Targeting Panel Goes Here</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'experiment-metrics',
|
||||
header: <MetricsPanelHeader experiment={experiment} />,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useValues } from 'kea'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { LemonSelect, LemonTag } from '@posthog/lemon-ui'
|
||||
|
||||
@@ -9,8 +8,8 @@ import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
|
||||
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
|
||||
import { ExperimentEventExposureConfig, NodeKind } from '~/queries/schema/schema-general'
|
||||
import { Experiment, FilterType } from '~/types'
|
||||
import { ExperimentEventExposureConfig, ExperimentExposureCriteria, NodeKind } from '~/queries/schema/schema-general'
|
||||
import type { Experiment, FilterType } from '~/types'
|
||||
|
||||
import { commonActionFilterProps } from '../Metrics/Selectors'
|
||||
import { SelectableCard } from '../components/SelectableCard'
|
||||
@@ -18,11 +17,12 @@ import { exposureConfigToFilter, filterToExposureConfig } from '../utils'
|
||||
|
||||
type ExposureCriteriaPanelProps = {
|
||||
experiment: Experiment
|
||||
onChange: (exposureCriteria: Experiment['exposure_criteria']) => void
|
||||
onChange: (exposureCriteria: ExperimentExposureCriteria) => void
|
||||
}
|
||||
|
||||
export function ExposureCriteriaPanel({ experiment, onChange }: ExposureCriteriaPanelProps): JSX.Element {
|
||||
const [selectedExposureType, setSelectedExposureType] = useState<'default' | 'custom'>('default')
|
||||
// Derive exposure type from experiment state
|
||||
const selectedExposureType = experiment.exposure_criteria?.exposure_config ? 'custom' : 'default'
|
||||
|
||||
const { currentTeam } = useValues(teamLogic)
|
||||
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
|
||||
@@ -45,7 +45,6 @@ export function ExposureCriteriaPanel({ experiment, onChange }: ExposureCriteria
|
||||
}
|
||||
selected={selectedExposureType === 'default'}
|
||||
onClick={() => {
|
||||
setSelectedExposureType('default')
|
||||
onChange({
|
||||
exposure_config: undefined,
|
||||
})
|
||||
@@ -61,7 +60,6 @@ export function ExposureCriteriaPanel({ experiment, onChange }: ExposureCriteria
|
||||
}
|
||||
selected={selectedExposureType === 'custom'}
|
||||
onClick={() => {
|
||||
setSelectedExposureType('custom')
|
||||
onChange({
|
||||
exposure_config: {
|
||||
kind: NodeKind.ExperimentEventExposureConfig,
|
||||
@@ -86,8 +84,8 @@ export function ExposureCriteriaPanel({ experiment, onChange }: ExposureCriteria
|
||||
properties: [],
|
||||
} as ExperimentEventExposureConfig)
|
||||
)}
|
||||
setFilters={({ events }: Partial<FilterType>): void => {
|
||||
const entity = events?.[0]
|
||||
setFilters={({ events, actions }: Partial<FilterType>): void => {
|
||||
const entity = events?.[0] || actions?.[0]
|
||||
if (entity) {
|
||||
onChange({
|
||||
exposure_config: filterToExposureConfig(entity),
|
||||
@@ -101,7 +99,7 @@ export function ExposureCriteriaPanel({ experiment, onChange }: ExposureCriteria
|
||||
entitiesLimit={1}
|
||||
mathAvailability={MathAvailability.None}
|
||||
showNumericalPropsOnly={false}
|
||||
actionsTaxonomicGroupTypes={[TaxonomicFilterGroupType.Events]}
|
||||
actionsTaxonomicGroupTypes={[TaxonomicFilterGroupType.Events, TaxonomicFilterGroupType.Actions]}
|
||||
propertiesTaxonomicGroupTypes={commonActionFilterProps.propertiesTaxonomicGroupTypes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -418,4 +418,201 @@ describe('VariantsPanel', () => {
|
||||
expect(screen.getByText('Create new feature flag')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('variant validation', () => {
|
||||
it('shows error when variant key is empty', async () => {
|
||||
const experimentWithEmptyKey: Experiment = {
|
||||
...NEW_EXPERIMENT,
|
||||
name: 'Test Experiment',
|
||||
feature_flag_key: 'test-experiment',
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{ key: 'control', rollout_percentage: 50 },
|
||||
{ key: '', rollout_percentage: 50 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<VariantsPanel experiment={experimentWithEmptyKey} updateFeatureFlag={mockUpdateFeatureFlag} />)
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText('All variants must have a key.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error when variant keys are duplicated', async () => {
|
||||
const experimentWithDuplicateKeys: Experiment = {
|
||||
...NEW_EXPERIMENT,
|
||||
name: 'Test Experiment',
|
||||
feature_flag_key: 'test-experiment',
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{ key: 'control', rollout_percentage: 50 },
|
||||
{ key: 'control', rollout_percentage: 50 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<VariantsPanel experiment={experimentWithDuplicateKeys} updateFeatureFlag={mockUpdateFeatureFlag} />)
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText('Variant keys must be unique.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error when variant has zero rollout and sum is not 100', async () => {
|
||||
const experimentWithZeroRollout: Experiment = {
|
||||
...NEW_EXPERIMENT,
|
||||
name: 'Test Experiment',
|
||||
feature_flag_key: 'test-experiment',
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{ key: 'control', rollout_percentage: 50 },
|
||||
{ key: 'test', rollout_percentage: 0 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<VariantsPanel experiment={experimentWithZeroRollout} updateFeatureFlag={mockUpdateFeatureFlag} />)
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText('All variants must have a rollout percentage greater than 0.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show zero rollout error when sum is 100', async () => {
|
||||
const experimentWithValidZeroRollout: Experiment = {
|
||||
...NEW_EXPERIMENT,
|
||||
name: 'Test Experiment',
|
||||
feature_flag_key: 'test-experiment',
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{ key: 'control', rollout_percentage: 100 },
|
||||
{ key: 'test', rollout_percentage: 0 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<VariantsPanel experiment={experimentWithValidZeroRollout} updateFeatureFlag={mockUpdateFeatureFlag} />
|
||||
)
|
||||
|
||||
// Should not show zero rollout error
|
||||
expect(
|
||||
screen.queryByText('All variants must have a rollout percentage greater than 0.')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights variant with error', async () => {
|
||||
const experimentWithEmptyKey: Experiment = {
|
||||
...NEW_EXPERIMENT,
|
||||
name: 'Test Experiment',
|
||||
feature_flag_key: 'test-experiment',
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{ key: 'control', rollout_percentage: 50 },
|
||||
{ key: '', rollout_percentage: 50 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<VariantsPanel experiment={experimentWithEmptyKey} updateFeatureFlag={mockUpdateFeatureFlag} />
|
||||
)
|
||||
|
||||
// Find variant rows
|
||||
const variantRows = container.querySelectorAll('.grid.grid-cols-24')
|
||||
|
||||
// First variant (control) should not have error highlighting
|
||||
expect(variantRows[1]).not.toHaveClass('bg-danger-highlight')
|
||||
|
||||
// Second variant (empty key) should have error highlighting
|
||||
expect(variantRows[2]).toHaveClass('bg-danger-highlight')
|
||||
expect(variantRows[2]).toHaveClass('border-danger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('callback payloads', () => {
|
||||
it('calls updateFeatureFlag with correct structure when selecting linked flag', async () => {
|
||||
render(<VariantsPanel experiment={defaultExperiment} updateFeatureFlag={mockUpdateFeatureFlag} />)
|
||||
|
||||
// Switch to link mode and select a flag
|
||||
const cards = screen.getAllByRole('button')
|
||||
const linkCard = cards.find((card) => card.textContent?.includes('Link existing'))
|
||||
await userEvent.click(linkCard!)
|
||||
|
||||
const selectButton = screen.getByRole('button', { name: /select feature flag/i })
|
||||
await userEvent.click(selectButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('eligible-flag-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const flagRows = screen.getAllByRole('row')
|
||||
const firstFlagRow = flagRows.find((row) => row.textContent?.includes('eligible-flag-1'))
|
||||
const selectFlagButton = within(firstFlagRow!).getByRole('button', { name: /select/i })
|
||||
await userEvent.click(selectFlagButton)
|
||||
|
||||
// Verify callback was called with correct structure
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFeatureFlag).toHaveBeenCalledWith({
|
||||
feature_flag_key: 'eligible-flag-1',
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{ key: 'control', rollout_percentage: 50 },
|
||||
{ key: 'variant-a', rollout_percentage: 50 },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('state restoration', () => {
|
||||
it('persists linked flag selection when switching link→create→link', async () => {
|
||||
render(<VariantsPanel experiment={defaultExperiment} updateFeatureFlag={mockUpdateFeatureFlag} />)
|
||||
|
||||
const cards = screen.getAllByRole('button')
|
||||
const linkCard = cards.find((card) => card.textContent?.includes('Link existing'))!
|
||||
const createCard = cards.find((card) => card.textContent?.includes('Create new'))!
|
||||
|
||||
// Step 1: Switch to link mode and select a flag
|
||||
await userEvent.click(linkCard)
|
||||
|
||||
const selectButton = screen.getByRole('button', { name: /select feature flag/i })
|
||||
await userEvent.click(selectButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('eligible-flag-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const flagRows = screen.getAllByRole('row')
|
||||
const firstFlagRow = flagRows.find((row) => row.textContent?.includes('eligible-flag-1'))
|
||||
const selectFlagButton = within(firstFlagRow!).getByRole('button', { name: /select/i })
|
||||
await userEvent.click(selectFlagButton)
|
||||
|
||||
// Verify flag is selected and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('eligible-flag-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('control')).toBeInTheDocument()
|
||||
expect(screen.getByText('variant-a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Step 2: Switch to create mode
|
||||
await userEvent.click(createCard)
|
||||
|
||||
// Should show create mode UI
|
||||
expect(screen.getByText('Feature flag key')).toBeInTheDocument()
|
||||
|
||||
// Step 3: Switch back to link mode
|
||||
await userEvent.click(linkCard)
|
||||
|
||||
// Should still show the previously selected flag
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('eligible-flag-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('control')).toBeInTheDocument()
|
||||
expect(screen.getByText('variant-a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify the flag is still displayed with its variants (not showing empty state)
|
||||
expect(screen.queryByText('No feature flag selected')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useState } from 'react'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
import { SelectableCard } from '~/scenes/experiments/components/SelectableCard'
|
||||
@@ -23,10 +22,8 @@ interface VariantsPanelProps {
|
||||
}
|
||||
|
||||
export function VariantsPanel({ experiment, updateFeatureFlag }: VariantsPanelProps): JSX.Element {
|
||||
const { mode } = useValues(variantsPanelLogic)
|
||||
const { setMode } = useActions(variantsPanelLogic)
|
||||
|
||||
const [linkedFeatureFlag, setLinkedFeatureFlag] = useState<FeatureFlagType | null>(null)
|
||||
const { mode, linkedFeatureFlag } = useValues(variantsPanelLogic)
|
||||
const { setMode, setLinkedFeatureFlag } = useActions(variantsPanelLogic)
|
||||
|
||||
const { openSelectExistingFeatureFlagModal, closeSelectExistingFeatureFlagModal } = useActions(
|
||||
selectExistingFeatureFlagModalLogic
|
||||
@@ -68,6 +65,13 @@ export function VariantsPanel({ experiment, updateFeatureFlag }: VariantsPanelPr
|
||||
onClose={() => closeSelectExistingFeatureFlagModal()}
|
||||
onSelect={(flag: FeatureFlagType) => {
|
||||
setLinkedFeatureFlag(flag)
|
||||
// Update experiment with linked flag's key and variants
|
||||
updateFeatureFlag({
|
||||
feature_flag_key: flag.key,
|
||||
parameters: {
|
||||
feature_flag_variants: flag.filters?.multivariate?.variants || [],
|
||||
},
|
||||
})
|
||||
closeSelectExistingFeatureFlagModal()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -428,16 +428,16 @@ describe('VariantsPanelCreateFeatureFlag', () => {
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
parameters: expect.objectContaining({
|
||||
ensure_experience_continuity: false,
|
||||
ensure_experience_continuity: true,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to checked', () => {
|
||||
it('defaults to unchecked', () => {
|
||||
renderComponent(defaultExperiment)
|
||||
|
||||
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(true)
|
||||
expect(checkbox.checked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -66,13 +66,28 @@ export const VariantsPanelCreateFeatureFlag = ({
|
||||
]
|
||||
|
||||
const ensureExperienceContinuity =
|
||||
(experiment.parameters as { ensure_experience_continuity?: boolean })?.ensure_experience_continuity ?? true
|
||||
(experiment.parameters as { ensure_experience_continuity?: boolean })?.ensure_experience_continuity ?? false
|
||||
|
||||
const variantRolloutSum = variants.reduce((sum, { rollout_percentage }) => sum + rollout_percentage, 0)
|
||||
const areVariantRolloutsValid =
|
||||
variants.every(({ rollout_percentage }) => rollout_percentage >= 0 && rollout_percentage <= 100) &&
|
||||
variantRolloutSum === 100
|
||||
|
||||
const areVariantKeysValid = variants.every(({ key }) => key && key.trim().length > 0)
|
||||
const variantKeys = variants.map(({ key }) => key)
|
||||
const hasDuplicateKeys = variantKeys.length !== new Set(variantKeys).size
|
||||
const hasZeroRolloutVariants =
|
||||
variants.some(({ rollout_percentage }) => rollout_percentage === 0) && variantRolloutSum !== 100
|
||||
|
||||
// Check if specific variant has an error
|
||||
const hasVariantError = (index: number): boolean => {
|
||||
const variant = variants[index]
|
||||
const isEmpty = !variant.key || variant.key.trim().length === 0
|
||||
const isDuplicate = variantKeys.filter((k) => k === variant.key).length > 1
|
||||
const hasZeroRollout = variant.rollout_percentage === 0 && variantRolloutSum !== 100
|
||||
return isEmpty || isDuplicate || hasZeroRollout
|
||||
}
|
||||
|
||||
const updateVariant = (index: number, updates: Partial<MultivariateFlagVariant>): void => {
|
||||
const newVariants = [...variants]
|
||||
newVariants[index] = { ...newVariants[index], ...updates }
|
||||
@@ -200,14 +215,24 @@ export const VariantsPanelCreateFeatureFlag = ({
|
||||
</div>
|
||||
</div>
|
||||
{variants.map((variant, index) => (
|
||||
<div key={variant.key} className="grid grid-cols-24 gap-2 mb-2">
|
||||
<div
|
||||
key={index}
|
||||
className={`grid grid-cols-24 gap-2 mb-2 p-2 rounded ${
|
||||
hasVariantError(index)
|
||||
? 'bg-danger-highlight border border-danger'
|
||||
: 'bg-transparent border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Lettermark name={alphabet[index]} color={LettermarkColor.Gray} />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<LemonInput
|
||||
value={variant.key}
|
||||
onChange={(value) => updateVariant(index, { key: value })}
|
||||
disabledReason={
|
||||
variant.key === 'control' ? 'Control variant cannot be changed' : null
|
||||
}
|
||||
onChange={(value) => updateVariant(index, { key: value.replace(/\s+/g, '-') })}
|
||||
data-attr="experiment-variant-key"
|
||||
data-key-index={index.toString()}
|
||||
className="ph-ignore-input"
|
||||
@@ -262,6 +287,15 @@ export const VariantsPanelCreateFeatureFlag = ({
|
||||
Percentage rollouts for variants must sum to 100 (currently {variantRolloutSum}).
|
||||
</p>
|
||||
)}
|
||||
{variants.length > 0 && !areVariantKeysValid && (
|
||||
<p className="text-danger">All variants must have a key.</p>
|
||||
)}
|
||||
{variants.length > 0 && hasDuplicateKeys && (
|
||||
<p className="text-danger">Variant keys must be unique.</p>
|
||||
)}
|
||||
{variants.length > 0 && hasZeroRolloutVariants && (
|
||||
<p className="text-danger">All variants must have a rollout percentage greater than 0.</p>
|
||||
)}
|
||||
{variants.length < MAX_EXPERIMENT_VARIANTS && (
|
||||
<LemonButton type="secondary" onClick={addVariant} icon={<IconPlus />} center>
|
||||
Add variant
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { actions, connect, kea, key, listeners, path, props, reducers } from 'kea'
|
||||
import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
|
||||
import { forms } from 'kea-forms'
|
||||
import { router } from 'kea-router'
|
||||
|
||||
@@ -12,8 +12,8 @@ import { teamLogic } from 'scenes/teamLogic'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { refreshTreeItem } from '~/layout/panel-layout/ProjectTree/projectTreeLogic'
|
||||
import { ExperimentMetric } from '~/queries/schema/schema-general'
|
||||
import type { Experiment, FeatureFlagFilters } from '~/types'
|
||||
import { ExperimentExposureCriteria, ExperimentMetric } from '~/queries/schema/schema-general'
|
||||
import type { Experiment, FeatureFlagFilters, MultivariateFlagVariant } from '~/types'
|
||||
import { ProductKey } from '~/types'
|
||||
|
||||
import { NEW_EXPERIMENT } from '../constants'
|
||||
@@ -53,6 +53,15 @@ export const createExperimentLogic = kea<createExperimentLogicType>([
|
||||
})),
|
||||
actions(() => ({
|
||||
setExperiment: (experiment: Experiment) => ({ experiment }),
|
||||
setExposureCriteria: (criteria: ExperimentExposureCriteria) => ({ criteria }),
|
||||
setFeatureFlagConfig: (config: {
|
||||
feature_flag_key?: string
|
||||
feature_flag_variants?: MultivariateFlagVariant[]
|
||||
parameters?: {
|
||||
feature_flag_variants?: MultivariateFlagVariant[]
|
||||
ensure_experience_continuity?: boolean
|
||||
}
|
||||
}) => ({ config }),
|
||||
createExperiment: () => ({}),
|
||||
createExperimentSuccess: true,
|
||||
setSharedMetrics: (sharedMetrics: { primary: ExperimentMetric[]; secondary: ExperimentMetric[] }) => ({
|
||||
@@ -64,6 +73,27 @@ export const createExperimentLogic = kea<createExperimentLogicType>([
|
||||
(props.experiment ?? { ...NEW_EXPERIMENT }) as Experiment & { feature_flag_filters?: FeatureFlagFilters },
|
||||
{
|
||||
setExperiment: (_, { experiment }) => experiment,
|
||||
setExposureCriteria: (state, { criteria }) => ({
|
||||
...state,
|
||||
exposure_criteria: {
|
||||
...state.exposure_criteria,
|
||||
...criteria,
|
||||
},
|
||||
}),
|
||||
setFeatureFlagConfig: (state, { config }) => ({
|
||||
...state,
|
||||
...(config.feature_flag_key !== undefined && {
|
||||
feature_flag_key: config.feature_flag_key,
|
||||
}),
|
||||
parameters: {
|
||||
...state.parameters,
|
||||
// Handle both flat structure (feature_flag_variants) and nested (parameters.*)
|
||||
...(config.feature_flag_variants !== undefined && {
|
||||
feature_flag_variants: config.feature_flag_variants,
|
||||
}),
|
||||
...(config.parameters && config.parameters),
|
||||
},
|
||||
}),
|
||||
updateFeatureFlagKey: (state, { key }) => ({ ...state, feature_flag_key: key }),
|
||||
resetExperiment: () => props.experiment ?? { ...NEW_EXPERIMENT },
|
||||
},
|
||||
@@ -75,6 +105,17 @@ export const createExperimentLogic = kea<createExperimentLogicType>([
|
||||
},
|
||||
],
|
||||
})),
|
||||
selectors(() => ({
|
||||
isValidDraft: [
|
||||
(s) => [s.experiment],
|
||||
(experiment: Experiment) => {
|
||||
const hasStartDate = experiment.start_date !== null
|
||||
const hasFeatureFlagKey = experiment.feature_flag_key !== null
|
||||
|
||||
return hasStartDate && hasFeatureFlagKey
|
||||
},
|
||||
],
|
||||
})),
|
||||
listeners(({ values, actions }) => ({
|
||||
setExperiment: () => {},
|
||||
setExperimentValue: () => {},
|
||||
|
||||
@@ -20,8 +20,15 @@ export const variantsPanelLogic = kea<variantsPanelLogicType>({
|
||||
experiment: Experiment
|
||||
},
|
||||
connect: {
|
||||
values: [featureFlagsLogic, ['featureFlags'], experimentsLogic, ['experiments']],
|
||||
actions: [createExperimentLogic, ['setExperimentValue']],
|
||||
values: [
|
||||
featureFlagsLogic,
|
||||
['featureFlags'],
|
||||
experimentsLogic,
|
||||
['experiments'],
|
||||
createExperimentLogic,
|
||||
['experiment'],
|
||||
],
|
||||
actions: [createExperimentLogic, ['setExperimentValue', 'setFeatureFlagConfig']],
|
||||
},
|
||||
actions: {
|
||||
validateFeatureFlagKey: (key: string) => ({ key }),
|
||||
@@ -32,6 +39,7 @@ export const variantsPanelLogic = kea<variantsPanelLogicType>({
|
||||
generateFeatureFlagKey: (name: string) => ({ name }),
|
||||
setMode: (mode: 'create' | 'link') => ({ mode }),
|
||||
setFeatureFlagKeyDirty: true,
|
||||
setLinkedFeatureFlag: (flag: FeatureFlagType | null) => ({ flag }),
|
||||
},
|
||||
reducers: {
|
||||
featureFlagKeyError: [
|
||||
@@ -53,6 +61,12 @@ export const variantsPanelLogic = kea<variantsPanelLogicType>({
|
||||
setMode: () => false, // Reset dirty flag when switching modes
|
||||
},
|
||||
],
|
||||
linkedFeatureFlag: [
|
||||
null as FeatureFlagType | null,
|
||||
{
|
||||
setLinkedFeatureFlag: (_, { flag }) => flag,
|
||||
},
|
||||
],
|
||||
},
|
||||
loaders: ({ values }) => ({
|
||||
featureFlagKeyValidation: [
|
||||
@@ -172,10 +186,7 @@ export const variantsPanelLogic = kea<variantsPanelLogicType>({
|
||||
])
|
||||
},
|
||||
],
|
||||
featureFlagKey: [
|
||||
(_, props) => [props.experiment],
|
||||
(experiment: Experiment): string => experiment.feature_flag_key || '',
|
||||
],
|
||||
featureFlagKey: [(s) => [s.experiment], (experiment: Experiment): string => experiment.feature_flag_key || ''],
|
||||
},
|
||||
listeners: ({ values, actions }) => ({
|
||||
[createExperimentLogic.actionTypes.setExperimentValue]: ({ name, value }) => {
|
||||
@@ -185,9 +196,27 @@ export const variantsPanelLogic = kea<variantsPanelLogicType>({
|
||||
},
|
||||
generateFeatureFlagKeySuccess: ({ generatedKey }) => {
|
||||
if (generatedKey) {
|
||||
actions.setExperimentValue('feature_flag_key', generatedKey)
|
||||
actions.setFeatureFlagConfig({ feature_flag_key: generatedKey })
|
||||
actions.validateFeatureFlagKey(generatedKey)
|
||||
}
|
||||
},
|
||||
setMode: ({ mode }) => {
|
||||
// When switching from link to create, validate the current key to show it's taken
|
||||
// Note: We use values.experiment (from createExperimentLogic connection) instead of props.experiment
|
||||
// because props are captured at mount time and don't update when the parent logic changes state
|
||||
if (mode === 'create' && values.experiment.feature_flag_key) {
|
||||
actions.validateFeatureFlagKey(values.experiment.feature_flag_key)
|
||||
}
|
||||
|
||||
// When switching to link mode, restore the linked flag's key/variants to experiment state
|
||||
if (mode === 'link' && values.linkedFeatureFlag) {
|
||||
actions.setFeatureFlagConfig({
|
||||
feature_flag_key: values.linkedFeatureFlag.key,
|
||||
parameters: {
|
||||
feature_flag_variants: values.linkedFeatureFlag.filters?.multivariate?.variants || [],
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user