From 7eef7bf44a3a9001cb5b078d1b09073be49407ab Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 25 May 2026 17:15:18 +0200 Subject: [PATCH 01/17] feat(experimentation): add Experiment types to frontend --- frontend/common/types/requests.ts | 5 +++++ frontend/common/types/responses.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 7dd713129e41..0677227f4702 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -984,5 +984,10 @@ export type Req = { name?: string config?: Record } + getExperiments: { environmentId: string } + createExperiment: { + environmentId: string + body: { name: string; hypothesis: string; feature: number } + } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b3edece5bd24..66cd73e331f7 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -571,6 +571,20 @@ export type MultivariateOption = { export type FeatureType = 'STANDARD' | 'MULTIVARIATE' +export type ExperimentStatus = 'created' | 'running' | 'paused' | 'completed' + +export type Experiment = { + id: number + name: string + hypothesis: string + feature: number + status: ExperimentStatus + created_at: string + updated_at: string + started_at: string | null + ended_at: string | null +} + export enum TagStrategy { INTERSECTION = 'INTERSECTION', UNION = 'UNION', @@ -1343,5 +1357,7 @@ export type Res = { gitlabIssues: PagedResponse gitlabMergeRequests: PagedResponse warehouseConnections: WarehouseConnection[] + experiments: Experiment[] + experiment: Experiment // END OF TYPES } From c835318f4cba28916bcd7040246f00720040193f Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 25 May 2026 17:17:42 +0200 Subject: [PATCH 02/17] feat(experimentation): add RTK Query service for experiments --- frontend/common/services/useExperiment.ts | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/common/services/useExperiment.ts diff --git a/frontend/common/services/useExperiment.ts b/frontend/common/services/useExperiment.ts new file mode 100644 index 000000000000..9c0536d99d76 --- /dev/null +++ b/frontend/common/services/useExperiment.ts @@ -0,0 +1,30 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const experimentService = service + .enhanceEndpoints({ addTagTypes: ['Experiment'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createExperiment: builder.mutation< + Res['experiment'], + Req['createExperiment'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'Experiment' }], + query: ({ body, environmentId }) => ({ + body, + method: 'POST', + url: `environments/${environmentId}/experiments/`, + }), + }), + getExperiments: builder.query({ + providesTags: [{ id: 'LIST', type: 'Experiment' }], + query: ({ environmentId }) => ({ + url: `environments/${environmentId}/experiments/`, + }), + }), + }), + }) + +export const { useCreateExperimentMutation, useGetExperimentsQuery } = + experimentService From 17360b04fbaec51e6a806f34cd7c79f6c6feec02 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 25 May 2026 17:20:08 +0200 Subject: [PATCH 03/17] feat(experimentation): add wizard utility components --- .../experiments/LivePreviewPanel.tsx | 18 ++++ .../experiments/WizardNavButtons.tsx | 49 +++++++++ .../components/experiments/WizardStepper.tsx | 101 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 frontend/web/components/experiments/LivePreviewPanel.tsx create mode 100644 frontend/web/components/experiments/WizardNavButtons.tsx create mode 100644 frontend/web/components/experiments/WizardStepper.tsx diff --git a/frontend/web/components/experiments/LivePreviewPanel.tsx b/frontend/web/components/experiments/LivePreviewPanel.tsx new file mode 100644 index 000000000000..5a0ae932482b --- /dev/null +++ b/frontend/web/components/experiments/LivePreviewPanel.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react' + +const LivePreviewPanel: FC = () => { + return ( +
+
+
+ Live Preview +
+
+
+ ) +} + +export default LivePreviewPanel diff --git a/frontend/web/components/experiments/WizardNavButtons.tsx b/frontend/web/components/experiments/WizardNavButtons.tsx new file mode 100644 index 000000000000..ee28f673a036 --- /dev/null +++ b/frontend/web/components/experiments/WizardNavButtons.tsx @@ -0,0 +1,49 @@ +import { FC } from 'react' +import Button from 'components/base/forms/Button' +import { IonIcon } from '@ionic/react' +import { arrowBack, rocketOutline } from 'ionicons/icons' + +type WizardNavButtonsProps = { + currentStep: number + totalSteps: number + canContinue: boolean + isSubmitting?: boolean + onBack: () => void + onContinue: () => void + onLaunch: () => void +} + +const WizardNavButtons: FC = ({ + canContinue, + currentStep, + isSubmitting, + onBack, + onContinue, + onLaunch, + totalSteps, +}) => { + const isLastStep = currentStep === totalSteps - 1 + + return ( +
+ {currentStep > 0 && ( + + )} + {isLastStep ? ( + + ) : ( + + )} +
+ ) +} + +export default WizardNavButtons diff --git a/frontend/web/components/experiments/WizardStepper.tsx b/frontend/web/components/experiments/WizardStepper.tsx new file mode 100644 index 000000000000..17d3686cc38b --- /dev/null +++ b/frontend/web/components/experiments/WizardStepper.tsx @@ -0,0 +1,101 @@ +import { FC } from 'react' +import { IonIcon } from '@ionic/react' +import { checkmarkCircle } from 'ionicons/icons' +import cn from 'classnames' + +type StepDef = { + title: string + subtitle: string +} + +const STEPS: StepDef[] = [ + { + subtitle: 'Name, hypothesis, and the flag to experiment on', + title: 'Setup', + }, + { + subtitle: 'Choose who is exposed and how traffic is split', + title: 'Audience & Traffic', + }, + { subtitle: 'Pick the metrics that determine success', title: 'Measurement' }, + { + subtitle: 'Review your configuration and launch', + title: 'Review & Launch', + }, +] + +type WizardStepperProps = { + currentStep: number + completedSteps: Set + onStepClick: (step: number) => void +} + +const WizardStepper: FC = ({ + completedSteps, + currentStep, + onStepClick, +}) => { + return ( +
+ {STEPS.map((step, index) => { + const isActive = index === currentStep + const isCompleted = completedSteps.has(index) + const isClickable = isCompleted || index < currentStep + + return ( + + ) + })} +
+ ) +} + +export default WizardStepper From c7d0bc2a3ee097112c228fc1c85f5f2fe6490870 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 25 May 2026 17:23:11 +0200 Subject: [PATCH 04/17] feat(experimentation): add SetupStep with feature flag selector --- .../experiments/steps/SetupStep.tsx | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 frontend/web/components/experiments/steps/SetupStep.tsx diff --git a/frontend/web/components/experiments/steps/SetupStep.tsx b/frontend/web/components/experiments/steps/SetupStep.tsx new file mode 100644 index 000000000000..e2c8d49e7dc6 --- /dev/null +++ b/frontend/web/components/experiments/steps/SetupStep.tsx @@ -0,0 +1,183 @@ +import { FC, useMemo } from 'react' +import { ProjectFlag } from 'common/types/responses' +import { useGetFeatureListQuery } from 'common/services/useProjectFlag' +import useDebouncedSearch from 'common/useDebouncedSearch' +import Utils from 'common/utils/utils' +import Panel from 'components/base/grid/Panel' + +type SetupStepProps = { + name: string + hypothesis: string + selectedFeature: ProjectFlag | null + projectId: number + environmentId: string + onNameChange: (name: string) => void + onHypothesisChange: (hypothesis: string) => void + onFeatureSelect: (feature: ProjectFlag) => void +} + +const SetupStep: FC = ({ + environmentId, + hypothesis, + name, + onFeatureSelect, + onHypothesisChange, + onNameChange, + projectId, + selectedFeature, +}) => { + const { search, setSearchInput } = useDebouncedSearch('') + + const { data: featureList, isLoading: isFeaturesLoading } = + useGetFeatureListQuery({ + environmentId, + page: 1, + page_size: 50, + projectId, + search: search || undefined, + }) + + const multivariateFeatures = useMemo(() => { + if (!featureList?.results) return [] + return featureList.results.filter((f) => f.type === 'MULTIVARIATE') + }, [featureList]) + + const getVariationValue = ( + mv: ProjectFlag['multivariate_options'][number], + ) => { + 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 '' + } + + return ( +
+ +

+ Name the experiment and capture what you're trying to learn before + picking a flag. +

+ + + + onNameChange(Utils.safeParseEventValue(e)) + } + placeholder='e.g. Checkout Button Redesign' + /> + + + +