diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 8f2d3ccd8e35..d4cb4a2a6ed6 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1040,6 +1040,18 @@ export type Req = { hypothesis: string feature: number metrics: { metric: number; expected_direction: ExpectedDirection }[] + experiment_rollout: { + enabled: boolean + rollout_percentage: number + feature_state_value: { + type: 'integer' | 'string' | 'boolean' + value: string + } + multivariate_feature_state_values: { + multivariate_feature_option: number + percentage_allocation: number + }[] + } } } experimentAction: { environmentId: string; experimentId: number } diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.scss b/frontend/web/components/base/grid/ContentCard/ContentCard.scss index 2cbf8100b455..5e50e0bc0b12 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.scss +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.scss @@ -12,9 +12,14 @@ border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); + &--white { + background: var(--color-surface-default); + } + &__heading { display: flex; flex-direction: column; + gap: 6px; } &__header { @@ -35,7 +40,7 @@ &__title { font-size: var(--font-body-size, 0.875rem); - font-weight: var(--font-weight-regular, 400); + font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); margin: 0; } diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx index 16f252162db5..1bb60b20a88a 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx @@ -8,6 +8,7 @@ type ContentCardProps = { action?: ReactNode className?: string compact?: boolean + white?: boolean children: ReactNode } @@ -18,12 +19,14 @@ const ContentCard: FC = ({ compact, description, title, + white, }) => { return (
diff --git a/frontend/web/components/experiments/CreateExperimentWizard.tsx b/frontend/web/components/experiments/CreateExperimentWizard.tsx index a69e80f2a0ee..f7b6f6b8a660 100644 --- a/frontend/web/components/experiments/CreateExperimentWizard.tsx +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -1,12 +1,20 @@ -import { FC, useCallback, useMemo, useState } from 'react' +import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { ExpectedDirection, Metric, ProjectFlag } from 'common/types/responses' import { useCreateExperimentMutation } from 'common/services/useExperiment' +import { useGetFeatureStatesQuery } from 'common/services/useFeatureState' +import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' import { METRIC_DIRECTION_TO_EXPECTED_DIRECTION } from './constants' import WizardStepper from './WizardStepper' import WizardNavButtons from './WizardNavButtons' import LivePreviewPanel from './LivePreviewPanel' import SetupStep from './steps/SetupStep' -import AudienceStep from './steps/AudienceStep' +import RolloutStep from './steps/RolloutStep' +import { + VariationSplitEntry, + getControlPercentage, + getVariationSplitDefaults, + toRolloutFeatureValue, +} from './rollout' import MeasurementStep from './steps/MeasurementStep' import ReviewStep from './steps/ReviewStep' @@ -34,8 +42,39 @@ const CreateExperimentWizard: FC = ({ const [selectedMetric, setSelectedMetric] = useState(null) const [expectedDirection, setExpectedDirection] = useState(null) + const [rolloutPercentage, setRolloutPercentage] = useState(100) + const [variationSplit, setVariationSplit] = useState( + [], + ) const [completedSteps, setCompletedSteps] = useState>(new Set()) + const { getEnvironmentIdFromKey } = useProjectEnvironments(projectId) + const numericEnvId = getEnvironmentIdFromKey(environmentId) + + const { data: featureStatesData } = useGetFeatureStatesQuery( + { environment: numericEnvId, feature: selectedFeature?.id }, + { skip: !selectedFeature || !numericEnvId }, + ) + + const environmentFeatureState = useMemo( + () => + featureStatesData?.results?.find( + (state) => !state.feature_segment && !state.identity, + ), + [featureStatesData], + ) + + useEffect(() => { + setVariationSplit( + selectedFeature + ? getVariationSplitDefaults( + selectedFeature.multivariate_options, + environmentFeatureState?.multivariate_feature_state_values, + ) + : [], + ) + }, [selectedFeature, environmentFeatureState]) + const [createExperiment, { isLoading: isSubmitting }] = useCreateExperimentMutation() @@ -50,9 +89,14 @@ const CreateExperimentWizard: FC = ({ const isMeasurementValid = selectedMetric !== null && expectedDirection !== null + const controlPercentage = getControlPercentage(variationSplit) + const isRolloutValid = + rolloutPercentage > 0 && controlPercentage >= 0 && controlPercentage <= 100 + const stepValidity: Record = { 0: isStep1Valid, - 3: isStep1Valid && isMeasurementValid, + 1: isRolloutValid, + 3: isStep1Valid && isRolloutValid && isMeasurementValid, [MEASUREMENT_STEP]: isMeasurementValid, } const canContinue = stepValidity[currentStep] ?? true @@ -89,8 +133,16 @@ const CreateExperimentWizard: FC = ({ const doCreate = useCallback(async () => { if (!selectedFeature || !selectedMetric || !expectedDirection) return try { + const controlValue = + selectedFeature.environment_feature_state?.feature_state_value ?? '' await createExperiment({ body: { + experiment_rollout: { + enabled: false, + feature_state_value: toRolloutFeatureValue(controlValue), + multivariate_feature_state_values: variationSplit, + rollout_percentage: rolloutPercentage, + }, feature: selectedFeature.id, hypothesis: hypothesis.trim(), metrics: [ @@ -115,8 +167,10 @@ const CreateExperimentWizard: FC = ({ hypothesis, name, onCreated, + rolloutPercentage, selectedFeature, selectedMetric, + variationSplit, ]) const handleLaunch = useCallback(() => { @@ -126,8 +180,10 @@ const CreateExperimentWizard: FC = ({ This will start serving variations of{' '} {selectedFeature.name} to{' '} - 100% of all users in the environment. You can pause - or stop the experiment at any time. + + {rolloutPercentage}% of eligible identities in the environment + + . You can pause or stop the experiment at any time. ), noText: 'Cancel', @@ -135,7 +191,7 @@ const CreateExperimentWizard: FC = ({ title: 'Create experiment?', yesText: 'Create', }) - }, [selectedFeature, isMeasurementValid, doCreate]) + }, [selectedFeature, isMeasurementValid, rolloutPercentage, doCreate]) const renderStep = () => { switch (currentStep) { @@ -153,7 +209,15 @@ const CreateExperimentWizard: FC = ({ /> ) case 1: - return + return ( + + ) case 2: return ( = ({ selectedFeature={selectedFeature} selectedMetric={selectedMetric} expectedDirection={expectedDirection} + rolloutPercentage={rolloutPercentage} + variationSplit={variationSplit} onEditSetup={() => setCurrentStep(0)} onEditMeasurement={() => setCurrentStep(MEASUREMENT_STEP)} + onEditRollout={() => setCurrentStep(1)} /> ) default: diff --git a/frontend/web/components/experiments/DistributionBar/DistributionBar.scss b/frontend/web/components/experiments/DistributionBar/DistributionBar.scss new file mode 100644 index 000000000000..c919cd1bc296 --- /dev/null +++ b/frontend/web/components/experiments/DistributionBar/DistributionBar.scss @@ -0,0 +1,23 @@ +.distribution-bar { + display: flex; + height: 10px; + width: 100%; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--color-surface-emphasis); + + &__segment { + height: 100%; + transition: width var(--duration-fast) var(--easing-standard); + } + + &__segment--hatched { + background: repeating-linear-gradient( + 45deg, + var(--color-surface-emphasis), + var(--color-surface-emphasis) 5px, + var(--color-surface-default) 5px, + var(--color-surface-default) 10px + ); + } +} diff --git a/frontend/web/components/experiments/DistributionBar/DistributionBar.tsx b/frontend/web/components/experiments/DistributionBar/DistributionBar.tsx new file mode 100644 index 000000000000..c74796003d97 --- /dev/null +++ b/frontend/web/components/experiments/DistributionBar/DistributionBar.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react' +import cn from 'classnames' +import './DistributionBar.scss' + +export type DistributionBarSegment = { + key: string + weight: number + colour?: string + hatched?: boolean +} + +type DistributionBarProps = { + segments: DistributionBarSegment[] + className?: string +} + +const DistributionBar: FC = ({ className, segments }) => ( +
+ {segments.map((segment) => + segment.weight > 0 ? ( +
+ ) : null, + )} +
+) + +export default DistributionBar diff --git a/frontend/web/components/experiments/DistributionBar/index.ts b/frontend/web/components/experiments/DistributionBar/index.ts new file mode 100644 index 000000000000..2621e67d9e81 --- /dev/null +++ b/frontend/web/components/experiments/DistributionBar/index.ts @@ -0,0 +1,2 @@ +export { default } from './DistributionBar' +export type { DistributionBarSegment } from './DistributionBar' diff --git a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss new file mode 100644 index 000000000000..418e228d5d95 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss @@ -0,0 +1,123 @@ +.rollout-slider { + display: flex; + flex-direction: column; + gap: 18px; + padding: 4px 4px 32px; + + &__field { + position: relative; + align-self: flex-start; + + .input-container { + width: 64px; + margin: 0; + } + } + + &__field-input { + -moz-appearance: textfield; + appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + + &__field .input-container.input-underline input.rollout-slider__field-input { + padding-right: 22px; + } + + &__field-suffix { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + color: var(--color-text-secondary); + pointer-events: none; + } + + &__row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + } + + &__track { + position: relative; + width: 70%; + } + + &__input { + width: 100%; + appearance: none; + -webkit-appearance: none; + height: 6px; + border-radius: var(--radius-full); + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-text-action); + border: 2px solid var(--color-surface-default); + box-shadow: var(--shadow-sm); + } + + &::-moz-range-thumb { + width: 18px; + height: 18px; + border: 2px solid var(--color-surface-default); + border-radius: 50%; + background: var(--color-text-action); + box-shadow: var(--shadow-sm); + } + } + + &__ticks { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + } + + &__tick { + position: absolute; + transform: translateX(-50%); + width: 25%; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 2px 0 6px; + appearance: none; + background: transparent; + border: 0; + cursor: pointer; + + &:hover .rollout-slider__tick-mark { + background: var(--color-text-action); + } + + &:hover .rollout-slider__tick-label { + color: var(--color-text-default); + } + } + + &__tick-mark { + width: 2px; + height: 6px; + border-radius: var(--radius-full); + background: var(--color-border-default); + } + + &__tick-label { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx new file mode 100644 index 000000000000..00aed981772f --- /dev/null +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx @@ -0,0 +1,77 @@ +import { ChangeEvent, FC } from 'react' +import { colorSurfaceEmphasis, colorTextAction } from 'common/theme/tokens' +import Input from 'components/base/forms/Input' +import Utils from 'common/utils/utils' +import './RolloutSlider.scss' + +type RolloutSliderProps = { + value: number + onChange: (value: number) => void +} + +const TICKS = [0, 25, 50, 75, 100] + +const clamp = (value: number): number => Math.min(100, Math.max(0, value)) + +const RolloutSlider: FC = ({ onChange, value }) => { + const fill = `linear-gradient(to right, ${colorTextAction} ${value}%, ${colorSurfaceEmphasis} ${value}%)` + + const handleInputChange = (e: ChangeEvent) => { + const parsed = parseInt(Utils.safeParseEventValue(e), 10) + onChange(clamp(Number.isNaN(parsed) ? 0 : parsed)) + } + + return ( +
+
+ + % +
+ +
+
+ ) => + onChange(Number(e.target.value)) + } + className='rollout-slider__input' + style={{ background: fill }} + aria-label='Rollout percentage' + /> +
+ {TICKS.map((tick) => ( + + ))} +
+
+
+
+ ) +} + +export default RolloutSlider diff --git a/frontend/web/components/experiments/RolloutSlider/index.ts b/frontend/web/components/experiments/RolloutSlider/index.ts new file mode 100644 index 000000000000..3ba02de8ff38 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSlider/index.ts @@ -0,0 +1 @@ +export { default } from './RolloutSlider' diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss new file mode 100644 index 000000000000..8cbf8ea3c795 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss @@ -0,0 +1,64 @@ +.rollout-split { + display: flex; + flex-direction: column; + gap: 12px; + + &__rows { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + } + + &__name { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + } + + &__name-text { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__control-tag { + font-size: var(--font-caption-xs-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + background: var(--color-surface-emphasis); + padding: 2px 8px; + border-radius: var(--radius-sm); + } + + &__weight { + display: flex; + align-items: center; + gap: 6px; + + .input-container { + width: 72px; + margin: 0; + } + + input { + width: 100%; + text-align: center; + } + } + + &__hint { + font-size: var(--font-body-sm-size); + } +} diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx new file mode 100644 index 000000000000..454c377e8e15 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx @@ -0,0 +1,109 @@ +import { ChangeEvent, FC } from 'react' +import { MultivariateOption } from 'common/types/responses' +import Input from 'components/base/forms/Input' +import ErrorMessage from 'components/ErrorMessage' +import ColorSwatch from 'components/ColorSwatch' +import Utils from 'common/utils/utils' +import { getDefaultVariantKey } from 'common/utils/multivariate' +import { + CONTROL_COLOUR, + VariationSplitEntry, + getControlPercentage, + getVariationColour, +} from 'components/experiments/rollout' +import './RolloutSplitEditor.scss' + +type RolloutSplitEditorProps = { + multivariateOptions: MultivariateOption[] + variationSplit: VariationSplitEntry[] + onChange: (entries: VariationSplitEntry[]) => void +} + +const RolloutSplitEditor: FC = ({ + multivariateOptions, + onChange, + variationSplit, +}) => { + const controlPercentage = getControlPercentage(variationSplit) + const invalid = controlPercentage < 0 || controlPercentage > 100 + + const getPercentage = (optionId: number): number => + variationSplit.find((v) => v.multivariate_feature_option === optionId) + ?.percentage_allocation ?? 0 + + const setPercentage = (optionId: number, percentage: number) => + onChange( + variationSplit.map((entry) => + entry.multivariate_feature_option === optionId + ? { ...entry, percentage_allocation: percentage } + : entry, + ), + ) + + return ( +
+ {invalid && ( + + )} + +
+
+ + + Control + control + + + + % + +
+ + {multivariateOptions.map((option, index) => ( +
+ + + + {option.key || getDefaultVariantKey(index)} + + + + ) => { + const val = Utils.safeParseEventValue(e) + setPercentage(option.id, val ? parseFloat(val) : 0) + }} + /> + % + +
+ ))} +
+ +

+ Bucketing is deterministic on the SDK identifier. The same identity + always lands in the same variation. +

+
+ ) +} + +export default RolloutSplitEditor diff --git a/frontend/web/components/experiments/RolloutSplitEditor/index.ts b/frontend/web/components/experiments/RolloutSplitEditor/index.ts new file mode 100644 index 000000000000..54c540dc2bcb --- /dev/null +++ b/frontend/web/components/experiments/RolloutSplitEditor/index.ts @@ -0,0 +1 @@ +export { default } from './RolloutSplitEditor' diff --git a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss new file mode 100644 index 000000000000..862149ec16c3 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss @@ -0,0 +1,79 @@ +.rollout-summary { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + &__title { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__not-released { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-text-secondary); + + svg, + .icon { + color: var(--color-icon-default); + } + } + + &__legend { + display: flex; + } + + &__legend-item { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 0 4px; + min-width: 0; + text-align: center; + } + + &__legend-label { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__legend-value { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__note { + display: flex; + align-items: flex-start; + gap: 12px; + line-height: 1.6; + + svg, + .icon { + color: var(--color-icon-action); + flex-shrink: 0; + } + } +} diff --git a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx new file mode 100644 index 000000000000..ba115e013465 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx @@ -0,0 +1,90 @@ +import { FC } from 'react' +import { ProjectFlag } from 'common/types/responses' +import Icon from 'components/icons/Icon' +import DistributionBar from 'components/experiments/DistributionBar' +import { + VariationSplitEntry, + getRolloutSummaryRows, + getTrafficSegments, +} from 'components/experiments/rollout' +import './RolloutSummary.scss' + +type RolloutSummaryProps = { + selectedFeature: ProjectFlag + rolloutPercentage: number + variationSplit: VariationSplitEntry[] +} + +const formatPercentage = (value: number): string => + `${Number(value.toFixed(1))}%` + +const RolloutSummary: FC = ({ + rolloutPercentage, + selectedFeature, + variationSplit, +}) => { + const rows = getRolloutSummaryRows(selectedFeature, variationSplit) + const arms = getTrafficSegments( + selectedFeature, + variationSplit, + rolloutPercentage, + ) + .map((segment, index) => ({ + colour: segment.colour, + label: segment.label, + scaled: segment.percentage, + weight: rows[index]?.percentage ?? 0, + })) + .filter((arm) => arm.scaled > 0) + const notReleased = Math.max(0, 100 - rolloutPercentage) + + const barSegments = [ + ...arms.map((arm) => ({ + colour: arm.colour, + key: arm.label, + weight: arm.scaled, + })), + { hatched: true, key: 'not-released', weight: notReleased }, + ] + + return ( +
+
+ Rollout configuration + + + Not released to {notReleased}% + +
+ + + +
+ {arms.map((arm) => ( +
+ {arm.label} + + {formatPercentage(arm.weight)} + +
+ ))} +
+ +
+ + + {rolloutPercentage}% of eligible identities enter the experiment. +
+ Actual time-to-significance depends on traffic, baseline rate, and the + lift you're trying to detect. +
+
+
+ ) +} + +export default RolloutSummary diff --git a/frontend/web/components/experiments/RolloutSummary/index.ts b/frontend/web/components/experiments/RolloutSummary/index.ts new file mode 100644 index 000000000000..cd15a534883b --- /dev/null +++ b/frontend/web/components/experiments/RolloutSummary/index.ts @@ -0,0 +1 @@ +export { default } from './RolloutSummary' diff --git a/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx b/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx index f415afa95585..e5a5fef0da12 100644 --- a/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx +++ b/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx @@ -13,8 +13,8 @@ const STEPS: StepDef[] = [ title: 'Setup', }, { - subtitle: 'Choose who is exposed and how traffic is split', - title: 'Audience & Traffic', + subtitle: "Select how much traffic enters and how it's split", + title: 'Rollout configuration', }, { subtitle: 'Pick the metrics that determine success', diff --git a/frontend/web/components/experiments/__tests__/rollout.test.ts b/frontend/web/components/experiments/__tests__/rollout.test.ts new file mode 100644 index 000000000000..5619e7d8b3e8 --- /dev/null +++ b/frontend/web/components/experiments/__tests__/rollout.test.ts @@ -0,0 +1,118 @@ +import { + buildRolloutSummary, + getControlPercentage, + getEvenSplit, + getRolloutSummaryRows, + getTrafficSegments, + getVariationSplitDefaults, + toRolloutFeatureValue, +} from 'components/experiments/rollout' +import { MultivariateOption, ProjectFlag } from 'common/types/responses' + +const option = (over: Partial): MultivariateOption => ({ + boolean_value: undefined, + default_percentage_allocation: 0, + id: 1, + integer_value: undefined, + key: null, + string_value: '', + type: 'unicode', + uuid: 'u', + ...over, +}) + +const feature = (options: MultivariateOption[]): ProjectFlag => + ({ multivariate_options: options } as ProjectFlag) + +describe('rollout helpers', () => { + it('getEvenSplit splits weight evenly across control and variants', () => { + expect(getEvenSplit([option({ id: 10 }), option({ id: 11 })])).toEqual([ + { multivariate_feature_option: 10, percentage_allocation: 33 }, + { multivariate_feature_option: 11, percentage_allocation: 33 }, + ]) + }) + + it('getVariationSplitDefaults derives weights from the environment, falling back to feature defaults', () => { + expect( + getVariationSplitDefaults( + [ + option({ default_percentage_allocation: 60, id: 10 }), + option({ default_percentage_allocation: 40, id: 11 }), + ], + [{ multivariate_feature_option: 10, percentage_allocation: 70 }], + ), + ).toEqual([ + { multivariate_feature_option: 10, percentage_allocation: 70 }, + { multivariate_feature_option: 11, percentage_allocation: 40 }, + ]) + }) + + it('getControlPercentage is 100 minus the sum of the split', () => { + expect( + getControlPercentage([ + { multivariate_feature_option: 10, percentage_allocation: 30 }, + ]), + ).toBe(70) + }) + + it('getRolloutSummaryRows puts Control first, then variants by key/fallback', () => { + expect( + getRolloutSummaryRows( + feature([ + option({ id: 10, key: 'big', string_value: 'big' }), + option({ id: 11, key: null, string_value: 'small' }), + ]), + [ + { multivariate_feature_option: 10, percentage_allocation: 60 }, + { multivariate_feature_option: 11, percentage_allocation: 40 }, + ], + ), + ).toEqual([ + { label: 'Control', percentage: 0 }, + { label: 'big', percentage: 60 }, + { label: 'Variant_2', percentage: 40 }, + ]) + }) + + it('getTrafficSegments scales each arm by the rollout percentage', () => { + expect( + getTrafficSegments( + feature([option({ id: 10 }), option({ id: 11 })]), + [ + { multivariate_feature_option: 10, percentage_allocation: 40 }, + { multivariate_feature_option: 11, percentage_allocation: 30 }, + ], + 50, + ).map(({ label, percentage }) => ({ label, percentage })), + ).toEqual([ + { label: 'Control', percentage: 15 }, + { label: 'Variant_1', percentage: 20 }, + { label: 'Variant_2', percentage: 15 }, + ]) + }) + + it('toRolloutFeatureValue wraps a typed control value as { type, value }', () => { + expect(toRolloutFeatureValue('control')).toEqual({ + type: 'string', + value: 'control', + }) + expect(toRolloutFeatureValue(42)).toEqual({ type: 'integer', value: '42' }) + expect(toRolloutFeatureValue(true)).toEqual({ + type: 'boolean', + value: 'true', + }) + expect(toRolloutFeatureValue(null)).toEqual({ type: 'string', value: '' }) + }) + + it('buildRolloutSummary describes rollout and split in one sentence', () => { + expect( + buildRolloutSummary(42, [ + { label: 'Control', percentage: 0 }, + { label: 'big', percentage: 60 }, + { label: 'small', percentage: 40 }, + ]), + ).toBe( + '42% of eligible identities enter the experiment. Split: Control 0%, big 60%, small 40%.', + ) + }) +}) diff --git a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx index 0a1adc384414..8a70241e1c05 100644 --- a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx @@ -70,8 +70,9 @@ const ExperimentSummaryScorecard: FC = ({ Recommendation
- {summary.winnerName} is outperforming Control with{' '} - {summary.chanceToBest} probability of being the best variant. + {summary.winnerName} is outperforming{' '} + Control with {summary.chanceToBest} probability of + being the best variant.
) : ( diff --git a/frontend/web/components/experiments/rollout.ts b/frontend/web/components/experiments/rollout.ts new file mode 100644 index 000000000000..2d7b4a03460c --- /dev/null +++ b/frontend/web/components/experiments/rollout.ts @@ -0,0 +1,124 @@ +import { + FlagsmithValue, + MultivariateOption, + ProjectFlag, +} from 'common/types/responses' +import { getDefaultVariantKey } from 'common/utils/multivariate' +import { + CHART_COLOURS, + colorTextAction, + colorTextSuccess, +} from 'common/theme/tokens' + +export type VariationSplitEntry = { + multivariate_feature_option: number + percentage_allocation: number +} + +export type RolloutFeatureValue = { + type: 'integer' | 'string' | 'boolean' + value: string +} + +export const toRolloutFeatureValue = ( + value: FlagsmithValue, +): RolloutFeatureValue => { + if (typeof value === 'boolean') { + return { type: 'boolean', value: value ? 'true' : 'false' } + } + if (typeof value === 'number') { + return { type: 'integer', value: String(value) } + } + return { type: 'string', value: value ?? '' } +} + +export type RolloutSummaryRow = { + label: string + percentage: number +} + +export const CONTROL_COLOUR = colorTextSuccess +export const VARIATION_COLOURS = [colorTextAction, ...CHART_COLOURS] + +export const getVariationColour = (index: number): string => + VARIATION_COLOURS[index % VARIATION_COLOURS.length] + +export const getVariationSplitDefaults = ( + options: MultivariateOption[], + environmentValues: VariationSplitEntry[] = [], +): VariationSplitEntry[] => + options.map((option) => { + const override = environmentValues.find( + (value) => value.multivariate_feature_option === option.id, + ) + return { + multivariate_feature_option: option.id, + percentage_allocation: + override?.percentage_allocation || + option.default_percentage_allocation || + 0, + } + }) + +export const getEvenSplit = ( + options: MultivariateOption[], +): VariationSplitEntry[] => { + const slots = options.length + 1 + const base = Math.floor(100 / slots) + const remainder = 100 - base * slots + return options.map((option, index) => ({ + multivariate_feature_option: option.id, + percentage_allocation: base + (index + 1 < remainder ? 1 : 0), + })) +} + +export const getControlPercentage = ( + variationSplit: VariationSplitEntry[], +): number => + 100 - + variationSplit.reduce( + (total, entry) => total + (entry.percentage_allocation || 0), + 0, + ) + +export const getRolloutSummaryRows = ( + feature: ProjectFlag, + variationSplit: VariationSplitEntry[], +): RolloutSummaryRow[] => [ + { + label: 'Control', + percentage: Math.max(0, getControlPercentage(variationSplit)), + }, + ...feature.multivariate_options.map((option, index) => ({ + label: option.key || getDefaultVariantKey(index), + percentage: + variationSplit.find( + (entry) => entry.multivariate_feature_option === option.id, + )?.percentage_allocation ?? 0, + })), +] + +export type TrafficSegment = { + label: string + percentage: number + colour: string +} + +export const getTrafficSegments = ( + feature: ProjectFlag, + variationSplit: VariationSplitEntry[], + rolloutPercentage: number, +): TrafficSegment[] => + getRolloutSummaryRows(feature, variationSplit).map((row, index) => ({ + colour: index === 0 ? CONTROL_COLOUR : getVariationColour(index - 1), + label: row.label, + percentage: (rolloutPercentage * row.percentage) / 100, + })) + +export const buildRolloutSummary = ( + rolloutPercentage: number, + rows: RolloutSummaryRow[], +): string => + `${rolloutPercentage}% of eligible identities enter the experiment. Split: ${rows + .map((row) => `${row.label} ${row.percentage}%`) + .join(', ')}.` diff --git a/frontend/web/components/experiments/steps/AudienceStep.tsx b/frontend/web/components/experiments/steps/AudienceStep.tsx deleted file mode 100644 index 202fb95f33a4..000000000000 --- a/frontend/web/components/experiments/steps/AudienceStep.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { FC } from 'react' -import Icon from 'components/icons/Icon' -import ContentCard from 'components/base/grid/ContentCard' - -const AudienceStep: FC = () => { - return ( -
- -
- -
-
All identities in this environment
-
- No targeting conditions. Every identity is eligible for the - experiment. Add a condition to filter the audience. -
-
-
-
-
- ) -} - -export default AudienceStep diff --git a/frontend/web/components/experiments/steps/MeasurementStep.tsx b/frontend/web/components/experiments/steps/MeasurementStep.tsx index fb81f67d59c9..8cf8bd10d4e8 100644 --- a/frontend/web/components/experiments/steps/MeasurementStep.tsx +++ b/frontend/web/components/experiments/steps/MeasurementStep.tsx @@ -29,6 +29,7 @@ const MeasurementStep: FC = ({ return (
diff --git a/frontend/web/components/experiments/steps/ReviewStep.tsx b/frontend/web/components/experiments/steps/ReviewStep.tsx index 3053d6bce839..1339cb5f17a2 100644 --- a/frontend/web/components/experiments/steps/ReviewStep.tsx +++ b/frontend/web/components/experiments/steps/ReviewStep.tsx @@ -4,6 +4,11 @@ import Button from 'components/base/forms/Button' import ContentCard from 'components/base/grid/ContentCard' import VariationTable from 'components/experiments/VariationTable' import { getExpectedDirectionLabel } from 'components/experiments/constants' +import { + VariationSplitEntry, + buildRolloutSummary, + getRolloutSummaryRows, +} from 'components/experiments/rollout' import './ReviewStep.scss' type ReviewStepProps = { @@ -12,8 +17,11 @@ type ReviewStepProps = { selectedFeature: ProjectFlag | null selectedMetric: Metric | null expectedDirection: ExpectedDirection | null + rolloutPercentage: number + variationSplit: VariationSplitEntry[] onEditSetup: () => void onEditMeasurement: () => void + onEditRollout: () => void } const ReviewStep: FC = ({ @@ -21,13 +29,17 @@ const ReviewStep: FC = ({ hypothesis, name, onEditMeasurement, + onEditRollout, onEditSetup, + rolloutPercentage, selectedFeature, selectedMetric, + variationSplit, }) => { return (
@@ -64,7 +76,27 @@ const ReviewStep: FC = ({ )} + {selectedFeature && ( + + Edit + + } + > +

+ {buildRolloutSummary( + rolloutPercentage, + getRolloutSummaryRows(selectedFeature, variationSplit), + )} +

+
+ )} + diff --git a/frontend/web/components/experiments/steps/RolloutStep.tsx b/frontend/web/components/experiments/steps/RolloutStep.tsx new file mode 100644 index 000000000000..d9549d40af37 --- /dev/null +++ b/frontend/web/components/experiments/steps/RolloutStep.tsx @@ -0,0 +1,80 @@ +import { FC } from 'react' +import { ProjectFlag } from 'common/types/responses' +import Button from 'components/base/forms/Button' +import ContentCard from 'components/base/grid/ContentCard' +import RolloutSlider from 'components/experiments/RolloutSlider' +import RolloutSplitEditor from 'components/experiments/RolloutSplitEditor' +import RolloutSummary from 'components/experiments/RolloutSummary' +import { + VariationSplitEntry, + getEvenSplit, +} from 'components/experiments/rollout' + +type RolloutStepProps = { + selectedFeature: ProjectFlag | null + rolloutPercentage: number + variationSplit: VariationSplitEntry[] + onRolloutChange: (value: number) => void + onSplitChange: (entries: VariationSplitEntry[]) => void +} + +const RolloutStep: FC = ({ + onRolloutChange, + onSplitChange, + rolloutPercentage, + selectedFeature, + variationSplit, +}) => { + if (!selectedFeature) { + return ( + +

+ Select a feature flag in the Setup step to configure the rollout. +

+
+ ) + } + + return ( +
+ + + + + + onSplitChange(getEvenSplit(selectedFeature.multivariate_options)) + } + > + Split evenly + + } + > + + + + +
+ ) +} + +export default RolloutStep diff --git a/frontend/web/components/experiments/steps/SetupStep.tsx b/frontend/web/components/experiments/steps/SetupStep.tsx index e24720c7bd5b..988561191d93 100644 --- a/frontend/web/components/experiments/steps/SetupStep.tsx +++ b/frontend/web/components/experiments/steps/SetupStep.tsx @@ -67,6 +67,7 @@ const SetupStep: FC = ({ return (
@@ -100,6 +101,7 @@ const SetupStep: FC = ({