From 05c2d62758f685de4ec4217cbdb8a375e61e21b2 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 22 Jun 2026 12:07:40 +0200 Subject: [PATCH 01/12] feat: add rollout config util and types --- .../experiments/__tests__/rollout.test.ts | 82 +++++++++++++++++++ .../web/components/experiments/rollout.ts | 61 ++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 frontend/web/components/experiments/__tests__/rollout.test.ts create mode 100644 frontend/web/components/experiments/rollout.ts 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..40ba8c577a86 --- /dev/null +++ b/frontend/web/components/experiments/__tests__/rollout.test.ts @@ -0,0 +1,82 @@ +import { + buildRolloutSummary, + getControlPercentage, + getMultivariateOptionValue, + getRolloutSummaryRows, + getVariationSplitDefaults, +} 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('getMultivariateOptionValue renders a value as a string', () => { + expect( + getMultivariateOptionValue(option({ integer_value: 7, type: 'int' })), + ).toBe('7') + }) + + it('getVariationSplitDefaults maps options to id + default allocation', () => { + expect( + getVariationSplitDefaults([ + option({ default_percentage_allocation: 60, id: 10 }), + option({ default_percentage_allocation: 40, id: 11 }), + ]), + ).toEqual([ + { multivariate_feature_option: 10, percentage_allocation: 60 }, + { 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('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/rollout.ts b/frontend/web/components/experiments/rollout.ts new file mode 100644 index 000000000000..0c64ffc18099 --- /dev/null +++ b/frontend/web/components/experiments/rollout.ts @@ -0,0 +1,61 @@ +import { MultivariateOption, ProjectFlag } from 'common/types/responses' +import { getDefaultVariantKey } from 'common/utils/multivariate' + +export type VariationSplitEntry = { + multivariate_feature_option: number + percentage_allocation: number +} + +export type RolloutSummaryRow = { + label: string + percentage: number +} + +export const getMultivariateOptionValue = (mv: MultivariateOption): string => { + if (mv.type === 'unicode') return mv.string_value + if (mv.type === 'int') return String(mv.integer_value ?? '') + if (mv.type === 'bool') return String(mv.boolean_value ?? '') + return '' +} + +export const getVariationSplitDefaults = ( + options: MultivariateOption[], +): VariationSplitEntry[] => + options.map((option) => ({ + multivariate_feature_option: option.id, + percentage_allocation: option.default_percentage_allocation ?? 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 const buildRolloutSummary = ( + rolloutPercentage: number, + rows: RolloutSummaryRow[], +): string => + `${rolloutPercentage}% of eligible identities enter the experiment. Split: ${rows + .map((row) => `${row.label} ${row.percentage}%`) + .join(', ')}.` From 722ebb83e374a20e65e8afa1bd9b951cfc70e8ea Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 22 Jun 2026 12:08:37 +0200 Subject: [PATCH 02/12] feat: add experiment_rollout to create experiment request type --- frontend/common/types/requests.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 7a8802c56a47..3de2ad15b8cb 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1040,6 +1040,15 @@ export type Req = { hypothesis: string feature: number metrics: { metric: number; expected_direction: ExpectedDirection }[] + experiment_rollout: { + enabled: boolean + rollout_percentage: number + feature_state_value: FeatureStateValue + multivariate_feature_state_values: { + multivariate_feature_option: number + percentage_allocation: number + }[] + } } } experimentAction: { environmentId: string; experimentId: number } From 2b0f0b57eadbd5879ff7b38d8423b5bd61d1c25d Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 22 Jun 2026 12:10:31 +0200 Subject: [PATCH 03/12] feat: add rollout slider component --- .../RolloutSlider/RolloutSlider.scss | 51 +++++++++++++++++++ .../RolloutSlider/RolloutSlider.tsx | 33 ++++++++++++ .../experiments/RolloutSlider/index.ts | 1 + 3 files changed, 85 insertions(+) create mode 100644 frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss create mode 100644 frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx create mode 100644 frontend/web/components/experiments/RolloutSlider/index.ts diff --git a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss new file mode 100644 index 000000000000..de6432ac0fb1 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss @@ -0,0 +1,51 @@ +.rollout-slider { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 8px 4px; + + &__readout { + font-size: 2rem; + font-weight: var(--font-weight-bold); + color: var(--color-text-default); + } + + &__input { + width: 100%; + appearance: none; + -webkit-appearance: none; + height: 6px; + border-radius: var(--radius-full); + background: var(--color-surface-muted); + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--color-text-action); + border: 2px solid var(--color-surface-default); + box-shadow: var(--shadow-sm); + } + + &::-moz-range-thumb { + width: 20px; + height: 20px; + border: 2px solid var(--color-surface-default); + border-radius: 50%; + background: var(--color-text-action); + box-shadow: var(--shadow-sm); + } + } + + &__scale { + width: 100%; + display: flex; + justify-content: space-between; + 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..2cb41b6f2041 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx @@ -0,0 +1,33 @@ +import { ChangeEvent, FC } from 'react' +import './RolloutSlider.scss' + +type RolloutSliderProps = { + value: number + onChange: (value: number) => void +} + +const RolloutSlider: FC = ({ onChange, value }) => { + return ( +
+
{value}%
+ ) => + onChange(Number(e.target.value)) + } + className='rollout-slider__input' + aria-label='Rollout percentage' + /> +
+ 0% + 100% +
+
+ ) +} + +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' From 299c6399cfaa613fd9fd8dcc6333d8a2ab4ebf53 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 22 Jun 2026 12:12:36 +0200 Subject: [PATCH 04/12] feat: add editable rollout variation split editor --- .../RolloutSplitEditor.scss | 66 ++++++++++ .../RolloutSplitEditor/RolloutSplitEditor.tsx | 114 ++++++++++++++++++ .../experiments/RolloutSplitEditor/index.ts | 1 + 3 files changed, 181 insertions(+) create mode 100644 frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss create mode 100644 frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx create mode 100644 frontend/web/components/experiments/RolloutSplitEditor/index.ts diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss new file mode 100644 index 000000000000..09f4cc5ee91c --- /dev/null +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss @@ -0,0 +1,66 @@ +.rollout-split { + &__row { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 4px; + border-top: 1px solid var(--color-border-default); + + &:first-of-type { + border-top: none; + } + } + + &__name { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 120px; + } + + &__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-muted); + padding: 2px 8px; + border-radius: var(--radius-sm); + } + + &__value { + flex: 1; + min-width: 0; + } + + &__value-badge { + display: inline-block; + font-family: var(--font-family); + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + background: var(--color-surface-muted); + padding: 6px 12px; + border-radius: var(--radius-md); + word-break: break-all; + max-width: 100%; + } + + &__weight { + display: flex; + align-items: center; + gap: 6px; + width: 100px; + justify-content: flex-end; + } + + &__weight-readonly { + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx new file mode 100644 index 000000000000..15015406a16a --- /dev/null +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx @@ -0,0 +1,114 @@ +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 { colorTextAction, colorTextSuccess } from 'common/theme/tokens' +import { + VariationSplitEntry, + getControlPercentage, + getMultivariateOptionValue, +} from 'components/experiments/rollout' +import './RolloutSplitEditor.scss' + +type RolloutSplitEditorProps = { + controlValue: string + multivariateOptions: MultivariateOption[] + variationSplit: VariationSplitEntry[] + onChange: (entries: VariationSplitEntry[]) => void +} + +const RolloutSplitEditor: FC = ({ + controlValue, + 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 +
+
+ {controlValue ? ( + + {controlValue} + + ) : null} +
+
+ + {Math.max(0, controlPercentage)} + + % +
+
+ + {multivariateOptions.map((option, index) => { + const value = getMultivariateOptionValue(option) + return ( +
+
+ + + {option.key || getDefaultVariantKey(index)} + +
+
+ {value ? ( + + {value} + + ) : null} +
+
+ ) => { + const val = Utils.safeParseEventValue(e) + setPercentage(option.id, val ? parseFloat(val) : 0) + }} + /> + % +
+
+ ) + })} +
+ ) +} + +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' From 4f8ef9d2791dada4fc666675c163c1f1fd34e1b8 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 22 Jun 2026 12:16:54 +0200 Subject: [PATCH 05/12] feat: wire rollout configuration step into experiment wizard --- .../experiments/CreateExperimentWizard.tsx | 66 +++++++++++++++-- .../WizardStepper/WizardStepper.tsx | 4 +- .../experiments/steps/AudienceStep.tsx | 32 -------- .../experiments/steps/RolloutStep.tsx | 74 +++++++++++++++++++ 4 files changed, 134 insertions(+), 42 deletions(-) delete mode 100644 frontend/web/components/experiments/steps/AudienceStep.tsx create mode 100644 frontend/web/components/experiments/steps/RolloutStep.tsx diff --git a/frontend/web/components/experiments/CreateExperimentWizard.tsx b/frontend/web/components/experiments/CreateExperimentWizard.tsx index a69e80f2a0ee..ad5d0e14a334 100644 --- a/frontend/web/components/experiments/CreateExperimentWizard.tsx +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -1,12 +1,23 @@ -import { FC, useCallback, useMemo, useState } from 'react' -import { ExpectedDirection, Metric, ProjectFlag } from 'common/types/responses' +import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import { + ExpectedDirection, + FeatureStateValue, + Metric, + ProjectFlag, +} from 'common/types/responses' import { useCreateExperimentMutation } from 'common/services/useExperiment' 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 Utils from 'common/utils/utils' +import { + VariationSplitEntry, + getControlPercentage, + getVariationSplitDefaults, +} from './rollout' import MeasurementStep from './steps/MeasurementStep' import ReviewStep from './steps/ReviewStep' @@ -34,8 +45,20 @@ 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()) + useEffect(() => { + setVariationSplit( + selectedFeature + ? getVariationSplitDefaults(selectedFeature.multivariate_options) + : [], + ) + }, [selectedFeature]) + const [createExperiment, { isLoading: isSubmitting }] = useCreateExperimentMutation() @@ -50,9 +73,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 +117,18 @@ 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: Utils.valueToFeatureState( + controlValue, + ) as FeatureStateValue, + multivariate_feature_state_values: variationSplit, + rollout_percentage: rolloutPercentage, + }, feature: selectedFeature.id, hypothesis: hypothesis.trim(), metrics: [ @@ -115,8 +153,10 @@ const CreateExperimentWizard: FC = ({ hypothesis, name, onCreated, + rolloutPercentage, selectedFeature, selectedMetric, + variationSplit, ]) const handleLaunch = useCallback(() => { @@ -126,8 +166,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 +177,7 @@ const CreateExperimentWizard: FC = ({ title: 'Create experiment?', yesText: 'Create', }) - }, [selectedFeature, isMeasurementValid, doCreate]) + }, [selectedFeature, isMeasurementValid, rolloutPercentage, doCreate]) const renderStep = () => { switch (currentStep) { @@ -153,7 +195,15 @@ const CreateExperimentWizard: FC = ({ /> ) case 1: - return + return ( + + ) case 2: return ( { - 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/RolloutStep.tsx b/frontend/web/components/experiments/steps/RolloutStep.tsx new file mode 100644 index 000000000000..92183c431b0d --- /dev/null +++ b/frontend/web/components/experiments/steps/RolloutStep.tsx @@ -0,0 +1,74 @@ +import { FC } from 'react' +import { ProjectFlag } from 'common/types/responses' +import ContentCard from 'components/base/grid/ContentCard' +import RolloutSlider from 'components/experiments/RolloutSlider' +import RolloutSplitEditor from 'components/experiments/RolloutSplitEditor' +import { + VariationSplitEntry, + buildRolloutSummary, + getRolloutSummaryRows, +} 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. +

+
+ ) + } + + const controlValue = + selectedFeature.environment_feature_state?.feature_state_value?.toString() ?? + '' + + return ( +
+ + + + + + + + + +

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

+
+
+ ) +} + +export default RolloutStep From 2a51d8b005722a134cac55784d7efaa20d912c50 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 22 Jun 2026 12:19:30 +0200 Subject: [PATCH 06/12] feat: show rollout summary in experiment review step --- .../experiments/CreateExperimentWizard.tsx | 3 ++ .../experiments/steps/ReviewStep.tsx | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/frontend/web/components/experiments/CreateExperimentWizard.tsx b/frontend/web/components/experiments/CreateExperimentWizard.tsx index ad5d0e14a334..a1514941f139 100644 --- a/frontend/web/components/experiments/CreateExperimentWizard.tsx +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -222,8 +222,11 @@ const CreateExperimentWizard: FC = ({ 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/steps/ReviewStep.tsx b/frontend/web/components/experiments/steps/ReviewStep.tsx index 3053d6bce839..51a8417913f5 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,9 +29,12 @@ const ReviewStep: FC = ({ hypothesis, name, onEditMeasurement, + onEditRollout, onEditSetup, + rolloutPercentage, selectedFeature, selectedMetric, + variationSplit, }) => { return (
@@ -64,6 +75,24 @@ const ReviewStep: FC = ({ )} + {selectedFeature && ( + + Edit + + } + > +

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

+
+ )} + Date: Tue, 23 Jun 2026 10:42:47 +0200 Subject: [PATCH 07/12] feat: refine rollout step to match design - Slider: primary fill up to the handle, 50% width centred, floating value above the handle (removes the large readout) - Variation split: row cards, Split evenly button, weight distribution bar, per-arm colours; drop variant value display; smaller weight inputs - Derive initial split weights from the environment feature state, not just the project default allocation - Add opt-in white prop to ContentCard and apply to wizard cards; add title/description spacing - Copy: Choose -> Select --- .../base/grid/ContentCard/ContentCard.scss | 5 + .../base/grid/ContentCard/ContentCard.tsx | 3 + .../experiments/CreateExperimentWizard.tsx | 4 +- .../RolloutSlider/RolloutSlider.scss | 32 +++-- .../RolloutSlider/RolloutSlider.tsx | 40 ++++--- .../RolloutSplitEditor.scss | 76 +++++++----- .../RolloutSplitEditor/RolloutSplitEditor.tsx | 112 +++++++++++------- .../WizardStepper/WizardStepper.tsx | 2 +- .../experiments/__tests__/rollout.test.ts | 43 +++++-- .../web/components/experiments/rollout.ts | 34 ++++-- .../experiments/steps/MeasurementStep.tsx | 1 + .../experiments/steps/ReviewStep.tsx | 3 + .../experiments/steps/RolloutStep.tsx | 28 +++-- .../experiments/steps/SetupStep.tsx | 2 + 14 files changed, 249 insertions(+), 136 deletions(-) diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.scss b/frontend/web/components/base/grid/ContentCard/ContentCard.scss index 2cbf8100b455..a34790cbd4cf 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 { 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 a1514941f139..5ea75924140d 100644 --- a/frontend/web/components/experiments/CreateExperimentWizard.tsx +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -53,9 +53,7 @@ const CreateExperimentWizard: FC = ({ useEffect(() => { setVariationSplit( - selectedFeature - ? getVariationSplitDefaults(selectedFeature.multivariate_options) - : [], + selectedFeature ? getVariationSplitDefaults(selectedFeature) : [], ) }, [selectedFeature]) diff --git a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss index de6432ac0fb1..61df0a81d080 100644 --- a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss @@ -1,14 +1,21 @@ .rollout-slider { display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - padding: 8px 4px; + justify-content: center; + padding: 28px 4px 8px; - &__readout { - font-size: 2rem; - font-weight: var(--font-weight-bold); + &__track { + position: relative; + width: 50%; + } + + &__value { + position: absolute; + top: -24px; + transform: translateX(-50%); + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); color: var(--color-text-default); + white-space: nowrap; } &__input { @@ -17,14 +24,13 @@ -webkit-appearance: none; height: 6px; border-radius: var(--radius-full); - background: var(--color-surface-muted); cursor: pointer; &::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 20px; - height: 20px; + width: 18px; + height: 18px; border-radius: 50%; background: var(--color-text-action); border: 2px solid var(--color-surface-default); @@ -32,8 +38,8 @@ } &::-moz-range-thumb { - width: 20px; - height: 20px; + width: 18px; + height: 18px; border: 2px solid var(--color-surface-default); border-radius: 50%; background: var(--color-text-action); @@ -42,7 +48,7 @@ } &__scale { - width: 100%; + margin-top: 8px; display: flex; justify-content: space-between; font-size: var(--font-caption-size); diff --git a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx index 2cb41b6f2041..fde65719ca3f 100644 --- a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx @@ -1,4 +1,5 @@ import { ChangeEvent, FC } from 'react' +import { colorSurfaceEmphasis, colorTextAction } from 'common/theme/tokens' import './RolloutSlider.scss' type RolloutSliderProps = { @@ -7,24 +8,31 @@ type RolloutSliderProps = { } const RolloutSlider: FC = ({ onChange, value }) => { + const fill = `linear-gradient(to right, ${colorTextAction} ${value}%, ${colorSurfaceEmphasis} ${value}%)` + return (
-
{value}%
- ) => - onChange(Number(e.target.value)) - } - className='rollout-slider__input' - aria-label='Rollout percentage' - /> -
- 0% - 100% +
+
+ {value}% +
+ ) => + onChange(Number(e.target.value)) + } + className='rollout-slider__input' + style={{ background: fill }} + aria-label='Rollout percentage' + /> +
+ 0% + 100% +
) diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss index 09f4cc5ee91c..f037e53d6463 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss @@ -1,22 +1,30 @@ .rollout-split { + display: flex; + flex-direction: column; + gap: 12px; + + &__rows { + display: flex; + flex-direction: column; + gap: 8px; + } + &__row { display: flex; align-items: center; - gap: 16px; - padding: 12px 4px; - border-top: 1px solid var(--color-border-default); - - &:first-of-type { - border-top: none; - } + 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: 8px; + gap: 10px; flex: 1; - min-width: 120px; + min-width: 0; } &__name-text { @@ -29,38 +37,42 @@ font-size: var(--font-caption-xs-size); font-weight: var(--font-weight-medium); color: var(--color-text-secondary); - background: var(--color-surface-muted); + background: var(--color-surface-emphasis); padding: 2px 8px; border-radius: var(--radius-sm); } - &__value { - flex: 1; - min-width: 0; - } - - &__value-badge { - display: inline-block; - font-family: var(--font-family); - font-size: var(--font-body-sm-size); - color: var(--color-text-default); - background: var(--color-surface-muted); - padding: 6px 12px; - border-radius: var(--radius-md); - word-break: break-all; - max-width: 100%; - } - &__weight { display: flex; align-items: center; gap: 6px; - width: 100px; - justify-content: flex-end; + + .input-container { + width: 56px; + margin: 0; + } + + input { + width: 100%; + text-align: center; + } } - &__weight-readonly { - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); + &__bar { + display: flex; + height: 10px; + width: 100%; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--color-surface-emphasis); + } + + &__bar-segment { + height: 100%; + transition: width var(--duration-fast) var(--easing-standard); + } + + &__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 index 15015406a16a..1764e8efc355 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx @@ -5,23 +5,30 @@ import ErrorMessage from 'components/ErrorMessage' import ColorSwatch from 'components/ColorSwatch' import Utils from 'common/utils/utils' import { getDefaultVariantKey } from 'common/utils/multivariate' -import { colorTextAction, colorTextSuccess } from 'common/theme/tokens' +import { + CHART_COLOURS, + colorTextAction, + colorTextSuccess, +} from 'common/theme/tokens' import { VariationSplitEntry, getControlPercentage, - getMultivariateOptionValue, } from 'components/experiments/rollout' import './RolloutSplitEditor.scss' +const CONTROL_COLOUR = colorTextSuccess +const VARIATION_COLOURS = [colorTextAction, ...CHART_COLOURS] + +const getVariationColour = (index: number): string => + VARIATION_COLOURS[index % VARIATION_COLOURS.length] + type RolloutSplitEditorProps = { - controlValue: string multivariateOptions: MultivariateOption[] variationSplit: VariationSplitEntry[] onChange: (entries: VariationSplitEntry[]) => void } const RolloutSplitEditor: FC = ({ - controlValue, multivariateOptions, onChange, variationSplit, @@ -42,6 +49,19 @@ const RolloutSplitEditor: FC = ({ ), ) + const segments = [ + { + colour: CONTROL_COLOUR, + key: 'control', + weight: Math.max(0, controlPercentage), + }, + ...multivariateOptions.map((option, index) => ({ + colour: getVariationColour(index), + key: String(option.id), + weight: getPercentage(option.id), + })), + ] + return (
{invalid && ( @@ -51,49 +71,41 @@ const RolloutSplitEditor: FC = ({ /> )} -
-
- - Control - control -
-
- {controlValue ? ( - - {controlValue} - - ) : null} -
-
- - {Math.max(0, controlPercentage)} +
+
+ + + Control + control + + + + % - %
-
- {multivariateOptions.map((option, index) => { - const value = getMultivariateOptionValue(option) - return ( + {multivariateOptions.map((option, index) => (
-
- + + {option.key || getDefaultVariantKey(index)} -
-
- {value ? ( - - {value} - - ) : null} -
-
+ + = ({ }} /> % -
+
- ) - })} + ))} +
+ +
+ {segments.map((segment) => + segment.weight > 0 ? ( +
+ ) : null, + )} +
+ +

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

) } diff --git a/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx b/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx index f4b3839d230d..e5a5fef0da12 100644 --- a/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx +++ b/frontend/web/components/experiments/WizardStepper/WizardStepper.tsx @@ -13,7 +13,7 @@ const STEPS: StepDef[] = [ title: 'Setup', }, { - subtitle: "Choose how much traffic enters and how it's split", + subtitle: "Select how much traffic enters and how it's split", title: 'Rollout configuration', }, { diff --git a/frontend/web/components/experiments/__tests__/rollout.test.ts b/frontend/web/components/experiments/__tests__/rollout.test.ts index 40ba8c577a86..d4167ff90070 100644 --- a/frontend/web/components/experiments/__tests__/rollout.test.ts +++ b/frontend/web/components/experiments/__tests__/rollout.test.ts @@ -1,7 +1,7 @@ import { buildRolloutSummary, getControlPercentage, - getMultivariateOptionValue, + getEvenSplit, getRolloutSummaryRows, getVariationSplitDefaults, } from 'components/experiments/rollout' @@ -19,24 +19,41 @@ const option = (over: Partial): MultivariateOption => ({ ...over, }) -const feature = (options: MultivariateOption[]): ProjectFlag => - ({ multivariate_options: options } as ProjectFlag) +const feature = ( + options: MultivariateOption[], + envValues?: { + multivariate_feature_option: number + percentage_allocation: number + }[], +): ProjectFlag => + ({ + environment_feature_state: envValues + ? { multivariate_feature_state_values: envValues } + : undefined, + multivariate_options: options, + } as ProjectFlag) describe('rollout helpers', () => { - it('getMultivariateOptionValue renders a value as a string', () => { - expect( - getMultivariateOptionValue(option({ integer_value: 7, type: 'int' })), - ).toBe('7') + 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 maps options to id + default allocation', () => { + 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 }), - ]), + getVariationSplitDefaults( + feature( + [ + 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: 60 }, + { multivariate_feature_option: 10, percentage_allocation: 70 }, { multivariate_feature_option: 11, percentage_allocation: 40 }, ]) }) diff --git a/frontend/web/components/experiments/rollout.ts b/frontend/web/components/experiments/rollout.ts index 0c64ffc18099..0ad55f3da006 100644 --- a/frontend/web/components/experiments/rollout.ts +++ b/frontend/web/components/experiments/rollout.ts @@ -11,20 +11,36 @@ export type RolloutSummaryRow = { percentage: number } -export const getMultivariateOptionValue = (mv: MultivariateOption): string => { - if (mv.type === 'unicode') return mv.string_value - if (mv.type === 'int') return String(mv.integer_value ?? '') - if (mv.type === 'bool') return String(mv.boolean_value ?? '') - return '' +export const getVariationSplitDefaults = ( + feature: ProjectFlag, +): VariationSplitEntry[] => { + const envValues = + feature.environment_feature_state?.multivariate_feature_state_values ?? [] + return feature.multivariate_options.map((option) => { + const override = envValues.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 getVariationSplitDefaults = ( +export const getEvenSplit = ( options: MultivariateOption[], -): VariationSplitEntry[] => - options.map((option) => ({ +): 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: option.default_percentage_allocation ?? 0, + percentage_allocation: base + (index + 1 < remainder ? 1 : 0), })) +} export const getControlPercentage = ( variationSplit: VariationSplitEntry[], 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 51a8417913f5..1339cb5f17a2 100644 --- a/frontend/web/components/experiments/steps/ReviewStep.tsx +++ b/frontend/web/components/experiments/steps/ReviewStep.tsx @@ -39,6 +39,7 @@ const ReviewStep: FC = ({ return (
@@ -77,6 +78,7 @@ const ReviewStep: FC = ({ {selectedFeature && ( @@ -94,6 +96,7 @@ const ReviewStep: FC = ({ )} diff --git a/frontend/web/components/experiments/steps/RolloutStep.tsx b/frontend/web/components/experiments/steps/RolloutStep.tsx index 92183c431b0d..da093b7124fc 100644 --- a/frontend/web/components/experiments/steps/RolloutStep.tsx +++ b/frontend/web/components/experiments/steps/RolloutStep.tsx @@ -1,11 +1,13 @@ 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 { VariationSplitEntry, buildRolloutSummary, + getEvenSplit, getRolloutSummaryRows, } from 'components/experiments/rollout' @@ -26,7 +28,7 @@ const RolloutStep: FC = ({ }) => { if (!selectedFeature) { return ( - +

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

@@ -34,32 +36,40 @@ const RolloutStep: FC = ({ ) } - const controlValue = - selectedFeature.environment_feature_state?.feature_state_value?.toString() ?? - '' - return (
+ onSplitChange(getEvenSplit(selectedFeature.multivariate_options)) + } + > + Split evenly + + } > - +

{buildRolloutSummary( rolloutPercentage, 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 = ({ From d03b4ed4336970aea821c943fd77428b28cff4ca Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 23 Jun 2026 11:06:36 +0200 Subject: [PATCH 08/12] feat: polish rollout step from design review - Fix initial split weights: a zero environment override no longer masks the feature default (match the | | fallback used elsewhere) - Slider track now 70% width; widen weight inputs so full numbers show - ContentCard titles semi-bold - Summary becomes a titleless grey callout with a people icon and bold variation names - Update Sample Size copy; drop em dash from the bucketing note --- .../base/grid/ContentCard/ContentCard.scss | 2 +- .../RolloutSlider/RolloutSlider.scss | 2 +- .../RolloutSplitEditor.scss | 2 +- .../RolloutSplitEditor/RolloutSplitEditor.tsx | 2 +- .../RolloutSummary/RolloutSummary.scss | 22 ++++++++++ .../RolloutSummary/RolloutSummary.tsx | 40 +++++++++++++++++++ .../experiments/RolloutSummary/index.ts | 1 + .../web/components/experiments/rollout.ts | 4 +- .../experiments/steps/RolloutStep.tsx | 18 ++++----- 9 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss create mode 100644 frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx create mode 100644 frontend/web/components/experiments/RolloutSummary/index.ts diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.scss b/frontend/web/components/base/grid/ContentCard/ContentCard.scss index a34790cbd4cf..5e50e0bc0b12 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.scss +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.scss @@ -40,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/experiments/RolloutSlider/RolloutSlider.scss b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss index 61df0a81d080..72ae2fd90a7a 100644 --- a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss @@ -5,7 +5,7 @@ &__track { position: relative; - width: 50%; + width: 70%; } &__value { diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss index f037e53d6463..c8d7856fed45 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss @@ -48,7 +48,7 @@ gap: 6px; .input-container { - width: 56px; + width: 72px; margin: 0; } diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx index 1764e8efc355..79f6d0c7c661 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx @@ -136,7 +136,7 @@ const RolloutSplitEditor: FC = ({

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

diff --git a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss new file mode 100644 index 000000000000..45671a12a6d0 --- /dev/null +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss @@ -0,0 +1,22 @@ +.rollout-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 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); + + svg, + .icon { + color: var(--color-icon-action); + flex-shrink: 0; + } + + strong { + color: var(--color-text-default); + font-weight: var(--font-weight-semibold); + } +} diff --git a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx new file mode 100644 index 000000000000..f3c0b314f4ed --- /dev/null +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx @@ -0,0 +1,40 @@ +import { FC, Fragment } from 'react' +import { ProjectFlag } from 'common/types/responses' +import Icon from 'components/icons/Icon' +import { + VariationSplitEntry, + getRolloutSummaryRows, +} from 'components/experiments/rollout' +import './RolloutSummary.scss' + +type RolloutSummaryProps = { + selectedFeature: ProjectFlag + rolloutPercentage: number + variationSplit: VariationSplitEntry[] +} + +const RolloutSummary: FC = ({ + rolloutPercentage, + selectedFeature, + variationSplit, +}) => { + const rows = getRolloutSummaryRows(selectedFeature, variationSplit) + + return ( +
+ + + {rolloutPercentage}% of eligible identities enter the experiment. Split:{' '} + {rows.map((row, index) => ( + + {index > 0 && ', '} + {row.label} {row.percentage}% + + ))} + . + +
+ ) +} + +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/rollout.ts b/frontend/web/components/experiments/rollout.ts index 0ad55f3da006..0de857003147 100644 --- a/frontend/web/components/experiments/rollout.ts +++ b/frontend/web/components/experiments/rollout.ts @@ -23,8 +23,8 @@ export const getVariationSplitDefaults = ( return { multivariate_feature_option: option.id, percentage_allocation: - override?.percentage_allocation ?? - option.default_percentage_allocation ?? + override?.percentage_allocation || + option.default_percentage_allocation || 0, } }) diff --git a/frontend/web/components/experiments/steps/RolloutStep.tsx b/frontend/web/components/experiments/steps/RolloutStep.tsx index da093b7124fc..cb768df16ae4 100644 --- a/frontend/web/components/experiments/steps/RolloutStep.tsx +++ b/frontend/web/components/experiments/steps/RolloutStep.tsx @@ -4,11 +4,10 @@ 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, - buildRolloutSummary, getEvenSplit, - getRolloutSummaryRows, } from 'components/experiments/rollout' type RolloutStepProps = { @@ -41,7 +40,7 @@ const RolloutStep: FC = ({ @@ -69,14 +68,11 @@ const RolloutStep: FC = ({ />
- -

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

-
+
) } From a73e0210bdec2fef0e226f1acd16249d3dbce54e Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 23 Jun 2026 11:12:11 +0200 Subject: [PATCH 09/12] feat: bold variant name and control in experiment recommendation --- .../experiments/results/ExperimentSummaryScorecard.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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.
) : ( From 0db2d28502e50c9718261678b5021551ca5668ef Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 23 Jun 2026 14:06:07 +0200 Subject: [PATCH 10/12] feat: load experiment variation weights from live environment feature state and refine rollout step UX - Fetch live environment feature state via getFeatureStates so variation weights seed from the per-environment allocations (the feature-list serializer omits multivariate_feature_state_values) - Redesign rollout summary: coloured swatches per arm, time-to-significance note, line breaks - Rework rollout slider: editable percentage input with inline % suffix, +/-5% steppers, clickable 0/25/50/75/100 ticks - Update sample size copy --- .../experiments/CreateExperimentWizard.tsx | 27 +++++- .../RolloutSlider/RolloutSlider.scss | 97 ++++++++++++++++--- .../RolloutSlider/RolloutSlider.tsx | 89 ++++++++++++++--- .../RolloutSplitEditor/RolloutSplitEditor.tsx | 13 +-- .../RolloutSummary/RolloutSummary.scss | 8 +- .../RolloutSummary/RolloutSummary.tsx | 17 +++- .../experiments/__tests__/rollout.test.ts | 27 ++---- .../web/components/experiments/rollout.ts | 23 +++-- .../experiments/steps/RolloutStep.tsx | 2 +- 9 files changed, 231 insertions(+), 72 deletions(-) diff --git a/frontend/web/components/experiments/CreateExperimentWizard.tsx b/frontend/web/components/experiments/CreateExperimentWizard.tsx index 5ea75924140d..c92946ac230b 100644 --- a/frontend/web/components/experiments/CreateExperimentWizard.tsx +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -6,6 +6,8 @@ import { 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' @@ -51,11 +53,32 @@ const CreateExperimentWizard: FC = ({ ) 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) : [], + selectedFeature + ? getVariationSplitDefaults( + selectedFeature.multivariate_options, + environmentFeatureState?.multivariate_feature_state_values, + ) + : [], ) - }, [selectedFeature]) + }, [selectedFeature, environmentFeatureState]) const [createExperiment, { isLoading: isSubmitting }] = useCreateExperimentMutation() diff --git a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss index 72ae2fd90a7a..abebeb8422c1 100644 --- a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss @@ -1,21 +1,56 @@ .rollout-slider { display: flex; - justify-content: center; - padding: 28px 4px 8px; + flex-direction: column; + gap: 18px; + padding: 4px 4px 32px; - &__track { + &__field { position: relative; - width: 70%; + align-self: flex-start; + + .input-container { + width: 64px; + margin: 0; + } + } + + &__field-input { + padding-right: 22px; + -moz-appearance: textfield; + appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } } - &__value { + &__field-suffix { position: absolute; - top: -24px; - transform: translateX(-50%); - font-size: var(--font-body-size); - font-weight: var(--font-weight-semibold); - color: var(--color-text-default); - white-space: nowrap; + 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; + } + + &__row &__step { + height: 20px; + line-height: 18px; + padding: 0 8px; + } + + &__track { + position: relative; + width: 70%; } &__input { @@ -47,10 +82,44 @@ } } - &__scale { - margin-top: 8px; + &__ticks { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + } + + &__tick { + position: absolute; + transform: translateX(-50%); + width: 25%; display: flex; - justify-content: space-between; + 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 index fde65719ca3f..16bd7dbc71f4 100644 --- a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx @@ -1,5 +1,8 @@ import { ChangeEvent, FC } from 'react' import { colorSurfaceEmphasis, colorTextAction } from 'common/theme/tokens' +import Button from 'components/base/forms/Button' +import Input from 'components/base/forms/Input' +import Utils from 'common/utils/utils' import './RolloutSlider.scss' type RolloutSliderProps = { @@ -7,32 +10,88 @@ type RolloutSliderProps = { onChange: (value: number) => void } +const STEP = 5 +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 (
-
-
- {value}% -
- + ) => - onChange(Number(e.target.value)) - } - className='rollout-slider__input' - style={{ background: fill }} + onChange={handleInputChange} + inputClassName='rollout-slider__field-input' aria-label='Rollout percentage' /> -
- 0% - 100% + % +
+ +
+ + +
+ ) => + onChange(Number(e.target.value)) + } + className='rollout-slider__input' + style={{ background: fill }} + aria-label='Rollout percentage' + /> +
+ {TICKS.map((tick) => ( + + ))} +
+ +
) diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx index 79f6d0c7c661..65fa2025f68d 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx @@ -6,22 +6,13 @@ import ColorSwatch from 'components/ColorSwatch' import Utils from 'common/utils/utils' import { getDefaultVariantKey } from 'common/utils/multivariate' import { - CHART_COLOURS, - colorTextAction, - colorTextSuccess, -} from 'common/theme/tokens' -import { + CONTROL_COLOUR, VariationSplitEntry, getControlPercentage, + getVariationColour, } from 'components/experiments/rollout' import './RolloutSplitEditor.scss' -const CONTROL_COLOUR = colorTextSuccess -const VARIATION_COLOURS = [colorTextAction, ...CHART_COLOURS] - -const getVariationColour = (index: number): string => - VARIATION_COLOURS[index % VARIATION_COLOURS.length] - type RolloutSplitEditorProps = { multivariateOptions: MultivariateOption[] variationSplit: VariationSplitEntry[] diff --git a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss index 45671a12a6d0..f9d3ee08026a 100644 --- a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss @@ -1,7 +1,8 @@ .rollout-summary { display: flex; - align-items: center; + align-items: flex-start; gap: 12px; + line-height: 1.6; padding: 14px 16px; background: var(--color-surface-subtle); border: 1px solid var(--color-border-default); @@ -19,4 +20,9 @@ color: var(--color-text-default); font-weight: var(--font-weight-semibold); } + + &__swatch { + margin-right: 4px; + vertical-align: middle; + } } diff --git a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx index f3c0b314f4ed..f5172bed02d8 100644 --- a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx @@ -1,9 +1,12 @@ import { FC, Fragment } from 'react' import { ProjectFlag } from 'common/types/responses' import Icon from 'components/icons/Icon' +import ColorSwatch from 'components/ColorSwatch' import { + CONTROL_COLOUR, VariationSplitEntry, getRolloutSummaryRows, + getVariationColour, } from 'components/experiments/rollout' import './RolloutSummary.scss' @@ -24,14 +27,26 @@ const RolloutSummary: FC = ({
- {rolloutPercentage}% of eligible identities enter the experiment. Split:{' '} + {rolloutPercentage}% of eligible identities enter the experiment. +
{rows.map((row, index) => ( {index > 0 && ', '} + {row.label} {row.percentage}% ))} . +
+ Actual time-to-significance depends on traffic, baseline rate, and the + lift you're trying to detect.
) diff --git a/frontend/web/components/experiments/__tests__/rollout.test.ts b/frontend/web/components/experiments/__tests__/rollout.test.ts index d4167ff90070..26a0f2985846 100644 --- a/frontend/web/components/experiments/__tests__/rollout.test.ts +++ b/frontend/web/components/experiments/__tests__/rollout.test.ts @@ -19,19 +19,8 @@ const option = (over: Partial): MultivariateOption => ({ ...over, }) -const feature = ( - options: MultivariateOption[], - envValues?: { - multivariate_feature_option: number - percentage_allocation: number - }[], -): ProjectFlag => - ({ - environment_feature_state: envValues - ? { multivariate_feature_state_values: envValues } - : undefined, - multivariate_options: options, - } as ProjectFlag) +const feature = (options: MultivariateOption[]): ProjectFlag => + ({ multivariate_options: options } as ProjectFlag) describe('rollout helpers', () => { it('getEvenSplit splits weight evenly across control and variants', () => { @@ -44,13 +33,11 @@ describe('rollout helpers', () => { it('getVariationSplitDefaults derives weights from the environment, falling back to feature defaults', () => { expect( getVariationSplitDefaults( - feature( - [ - option({ default_percentage_allocation: 60, id: 10 }), - option({ default_percentage_allocation: 40, id: 11 }), - ], - [{ multivariate_feature_option: 10, percentage_allocation: 70 }], - ), + [ + 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 }, diff --git a/frontend/web/components/experiments/rollout.ts b/frontend/web/components/experiments/rollout.ts index 0de857003147..56cb2b022f71 100644 --- a/frontend/web/components/experiments/rollout.ts +++ b/frontend/web/components/experiments/rollout.ts @@ -1,5 +1,10 @@ import { 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 @@ -11,13 +16,18 @@ export type RolloutSummaryRow = { 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 = ( - feature: ProjectFlag, -): VariationSplitEntry[] => { - const envValues = - feature.environment_feature_state?.multivariate_feature_state_values ?? [] - return feature.multivariate_options.map((option) => { - const override = envValues.find( + options: MultivariateOption[], + environmentValues: VariationSplitEntry[] = [], +): VariationSplitEntry[] => + options.map((option) => { + const override = environmentValues.find( (value) => value.multivariate_feature_option === option.id, ) return { @@ -28,7 +38,6 @@ export const getVariationSplitDefaults = ( 0, } }) -} export const getEvenSplit = ( options: MultivariateOption[], diff --git a/frontend/web/components/experiments/steps/RolloutStep.tsx b/frontend/web/components/experiments/steps/RolloutStep.tsx index cb768df16ae4..55753f0e288b 100644 --- a/frontend/web/components/experiments/steps/RolloutStep.tsx +++ b/frontend/web/components/experiments/steps/RolloutStep.tsx @@ -40,7 +40,7 @@ const RolloutStep: FC = ({ From 81675db0ab02d5cbb303cb2c9ace7adb123344e8 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 23 Jun 2026 15:50:31 +0200 Subject: [PATCH 11/12] feat: add traffic distribution preview to rollout summary - Extract shared DistributionBar component (control/variation arms + hatched not-released area) - Fold the traffic preview into the rollout summary as 'Rollout configuration': scaled bar with legend positioned under each segment showing the configured variant weight - Simplify the slider: borderless percentage input with inline % suffix, remove +/-5% steppers - Tidy sample size and summary copy --- .../DistributionBar/DistributionBar.scss | 23 +++++ .../DistributionBar/DistributionBar.tsx | 36 ++++++++ .../experiments/DistributionBar/index.ts | 2 + .../RolloutSlider/RolloutSlider.scss | 11 +-- .../RolloutSlider/RolloutSlider.tsx | 25 +----- .../RolloutSplitEditor.scss | 14 --- .../RolloutSplitEditor/RolloutSplitEditor.tsx | 28 ------ .../RolloutSummary/RolloutSummary.scss | 73 +++++++++++++--- .../RolloutSummary/RolloutSummary.tsx | 87 +++++++++++++------ .../experiments/__tests__/rollout.test.ts | 18 ++++ .../web/components/experiments/rollout.ts | 17 ++++ .../experiments/steps/RolloutStep.tsx | 2 +- 12 files changed, 225 insertions(+), 111 deletions(-) create mode 100644 frontend/web/components/experiments/DistributionBar/DistributionBar.scss create mode 100644 frontend/web/components/experiments/DistributionBar/DistributionBar.tsx create mode 100644 frontend/web/components/experiments/DistributionBar/index.ts 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 index abebeb8422c1..418e228d5d95 100644 --- a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.scss @@ -15,7 +15,6 @@ } &__field-input { - padding-right: 22px; -moz-appearance: textfield; appearance: textfield; @@ -26,6 +25,10 @@ } } + &__field .input-container.input-underline input.rollout-slider__field-input { + padding-right: 22px; + } + &__field-suffix { position: absolute; top: 50%; @@ -42,12 +45,6 @@ gap: 12px; } - &__row &__step { - height: 20px; - line-height: 18px; - padding: 0 8px; - } - &__track { position: relative; width: 70%; diff --git a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx index 16bd7dbc71f4..00aed981772f 100644 --- a/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx +++ b/frontend/web/components/experiments/RolloutSlider/RolloutSlider.tsx @@ -1,6 +1,5 @@ import { ChangeEvent, FC } from 'react' import { colorSurfaceEmphasis, colorTextAction } from 'common/theme/tokens' -import Button from 'components/base/forms/Button' import Input from 'components/base/forms/Input' import Utils from 'common/utils/utils' import './RolloutSlider.scss' @@ -10,7 +9,6 @@ type RolloutSliderProps = { onChange: (value: number) => void } -const STEP = 5 const TICKS = [0, 25, 50, 75, 100] const clamp = (value: number): number => Math.min(100, Math.max(0, value)) @@ -29,6 +27,7 @@ const RolloutSlider: FC = ({ onChange, value }) => { = ({ onChange, value }) => {
- -
= ({ onChange, value }) => { ))}
- -
) diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss index c8d7856fed45..8cbf8ea3c795 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.scss @@ -58,20 +58,6 @@ } } - &__bar { - display: flex; - height: 10px; - width: 100%; - border-radius: var(--radius-full); - overflow: hidden; - background: var(--color-surface-emphasis); - } - - &__bar-segment { - height: 100%; - transition: width var(--duration-fast) var(--easing-standard); - } - &__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 index 65fa2025f68d..454c377e8e15 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx @@ -40,19 +40,6 @@ const RolloutSplitEditor: FC = ({ ), ) - const segments = [ - { - colour: CONTROL_COLOUR, - key: 'control', - weight: Math.max(0, controlPercentage), - }, - ...multivariateOptions.map((option, index) => ({ - colour: getVariationColour(index), - key: String(option.id), - weight: getPercentage(option.id), - })), - ] - return (
{invalid && ( @@ -111,21 +98,6 @@ const RolloutSplitEditor: FC = ({ ))}
-
- {segments.map((segment) => - segment.weight > 0 ? ( -
- ) : null, - )} -
-

Bucketing is deterministic on the SDK identifier. The same identity always lands in the same variation. diff --git a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss index f9d3ee08026a..862149ec16c3 100644 --- a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.scss @@ -1,28 +1,79 @@ .rollout-summary { display: flex; - align-items: flex-start; + flex-direction: column; gap: 12px; - line-height: 1.6; - padding: 14px 16px; + 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); - svg, - .icon { - color: var(--color-icon-action); - flex-shrink: 0; + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } - strong { + &__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); } - &__swatch { - margin-right: 4px; - vertical-align: middle; + &__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 index f5172bed02d8..ba115e013465 100644 --- a/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx +++ b/frontend/web/components/experiments/RolloutSummary/RolloutSummary.tsx @@ -1,12 +1,11 @@ -import { FC, Fragment } from 'react' +import { FC } from 'react' import { ProjectFlag } from 'common/types/responses' import Icon from 'components/icons/Icon' -import ColorSwatch from 'components/ColorSwatch' +import DistributionBar from 'components/experiments/DistributionBar' import { - CONTROL_COLOUR, VariationSplitEntry, getRolloutSummaryRows, - getVariationColour, + getTrafficSegments, } from 'components/experiments/rollout' import './RolloutSummary.scss' @@ -16,38 +15,74 @@ type RolloutSummaryProps = { 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 (

- - - {rolloutPercentage}% of eligible identities enter the experiment. -
- {rows.map((row, index) => ( - - {index > 0 && ', '} - - {row.label} {row.percentage}% - +
+ Rollout configuration + + + Not released to {notReleased}% + +
+ + + +
+ {arms.map((arm) => ( +
+ {arm.label} + + {formatPercentage(arm.weight)} + +
))} - . -
- Actual time-to-significance depends on traffic, baseline rate, and the - lift you're trying to detect. - +
+ +
+ + + {rolloutPercentage}% of eligible identities enter the experiment. +
+ Actual time-to-significance depends on traffic, baseline rate, and the + lift you're trying to detect. +
+
) } diff --git a/frontend/web/components/experiments/__tests__/rollout.test.ts b/frontend/web/components/experiments/__tests__/rollout.test.ts index 26a0f2985846..c7f97b1bf8fb 100644 --- a/frontend/web/components/experiments/__tests__/rollout.test.ts +++ b/frontend/web/components/experiments/__tests__/rollout.test.ts @@ -3,6 +3,7 @@ import { getControlPercentage, getEvenSplit, getRolloutSummaryRows, + getTrafficSegments, getVariationSplitDefaults, } from 'components/experiments/rollout' import { MultivariateOption, ProjectFlag } from 'common/types/responses' @@ -72,6 +73,23 @@ describe('rollout helpers', () => { ]) }) + 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('buildRolloutSummary describes rollout and split in one sentence', () => { expect( buildRolloutSummary(42, [ diff --git a/frontend/web/components/experiments/rollout.ts b/frontend/web/components/experiments/rollout.ts index 56cb2b022f71..fd9d9a602512 100644 --- a/frontend/web/components/experiments/rollout.ts +++ b/frontend/web/components/experiments/rollout.ts @@ -77,6 +77,23 @@ export const getRolloutSummaryRows = ( })), ] +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[], diff --git a/frontend/web/components/experiments/steps/RolloutStep.tsx b/frontend/web/components/experiments/steps/RolloutStep.tsx index 55753f0e288b..d9549d40af37 100644 --- a/frontend/web/components/experiments/steps/RolloutStep.tsx +++ b/frontend/web/components/experiments/steps/RolloutStep.tsx @@ -40,7 +40,7 @@ const RolloutStep: FC = ({ From f327728725c1c64faeec9fb391d499978e7b2440 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 24 Jun 2026 11:10:17 +0200 Subject: [PATCH 12/12] feat: map experiment rollout control value to backend contract --- frontend/common/types/requests.ts | 5 +++- .../experiments/CreateExperimentWizard.tsx | 13 +++-------- .../experiments/__tests__/rollout.test.ts | 14 +++++++++++ .../web/components/experiments/rollout.ts | 23 ++++++++++++++++++- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 45723108b3fa..d4cb4a2a6ed6 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1043,7 +1043,10 @@ export type Req = { experiment_rollout: { enabled: boolean rollout_percentage: number - feature_state_value: FeatureStateValue + feature_state_value: { + type: 'integer' | 'string' | 'boolean' + value: string + } multivariate_feature_state_values: { multivariate_feature_option: number percentage_allocation: number diff --git a/frontend/web/components/experiments/CreateExperimentWizard.tsx b/frontend/web/components/experiments/CreateExperimentWizard.tsx index c92946ac230b..f7b6f6b8a660 100644 --- a/frontend/web/components/experiments/CreateExperimentWizard.tsx +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -1,10 +1,5 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react' -import { - ExpectedDirection, - FeatureStateValue, - Metric, - ProjectFlag, -} from 'common/types/responses' +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' @@ -14,11 +9,11 @@ import WizardNavButtons from './WizardNavButtons' import LivePreviewPanel from './LivePreviewPanel' import SetupStep from './steps/SetupStep' import RolloutStep from './steps/RolloutStep' -import Utils from 'common/utils/utils' import { VariationSplitEntry, getControlPercentage, getVariationSplitDefaults, + toRolloutFeatureValue, } from './rollout' import MeasurementStep from './steps/MeasurementStep' import ReviewStep from './steps/ReviewStep' @@ -144,9 +139,7 @@ const CreateExperimentWizard: FC = ({ body: { experiment_rollout: { enabled: false, - feature_state_value: Utils.valueToFeatureState( - controlValue, - ) as FeatureStateValue, + feature_state_value: toRolloutFeatureValue(controlValue), multivariate_feature_state_values: variationSplit, rollout_percentage: rolloutPercentage, }, diff --git a/frontend/web/components/experiments/__tests__/rollout.test.ts b/frontend/web/components/experiments/__tests__/rollout.test.ts index c7f97b1bf8fb..5619e7d8b3e8 100644 --- a/frontend/web/components/experiments/__tests__/rollout.test.ts +++ b/frontend/web/components/experiments/__tests__/rollout.test.ts @@ -5,6 +5,7 @@ import { getRolloutSummaryRows, getTrafficSegments, getVariationSplitDefaults, + toRolloutFeatureValue, } from 'components/experiments/rollout' import { MultivariateOption, ProjectFlag } from 'common/types/responses' @@ -90,6 +91,19 @@ describe('rollout helpers', () => { ]) }) + 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, [ diff --git a/frontend/web/components/experiments/rollout.ts b/frontend/web/components/experiments/rollout.ts index fd9d9a602512..2d7b4a03460c 100644 --- a/frontend/web/components/experiments/rollout.ts +++ b/frontend/web/components/experiments/rollout.ts @@ -1,4 +1,8 @@ -import { MultivariateOption, ProjectFlag } from 'common/types/responses' +import { + FlagsmithValue, + MultivariateOption, + ProjectFlag, +} from 'common/types/responses' import { getDefaultVariantKey } from 'common/utils/multivariate' import { CHART_COLOURS, @@ -11,6 +15,23 @@ export type VariationSplitEntry = { 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