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 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 } diff --git a/frontend/web/components/base/grid/ContentCard.scss b/frontend/web/components/base/grid/ContentCard.scss new file mode 100644 index 000000000000..02c4467acae0 --- /dev/null +++ b/frontend/web/components/base/grid/ContentCard.scss @@ -0,0 +1,22 @@ +.content-card { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__title { + font-size: var(--font-body-size, 0.875rem); + font-weight: var(--font-weight-bold, 700); + color: var(--color-text-default); + margin: 0; + } +} diff --git a/frontend/web/components/base/grid/ContentCard.tsx b/frontend/web/components/base/grid/ContentCard.tsx new file mode 100644 index 000000000000..2cb5136a3f88 --- /dev/null +++ b/frontend/web/components/base/grid/ContentCard.tsx @@ -0,0 +1,31 @@ +import { FC, ReactNode } from 'react' +import cn from 'classnames' +import './ContentCard.scss' + +type ContentCardProps = { + title?: string + action?: ReactNode + className?: string + children: ReactNode +} + +const ContentCard: FC = ({ + action, + children, + className, + title, +}) => { + return ( +
+ {(title || action) && ( +
+ {title &&

{title}

} + {action} +
+ )} + {children} +
+ ) +} + +export default ContentCard diff --git a/frontend/web/components/experiments/CreateExperimentWizard.tsx b/frontend/web/components/experiments/CreateExperimentWizard.tsx new file mode 100644 index 000000000000..040be0fc5cd6 --- /dev/null +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -0,0 +1,168 @@ +import { FC, useCallback, useMemo, useState } from 'react' +import { ProjectFlag } from 'common/types/responses' +import { useCreateExperimentMutation } from 'common/services/useExperiment' +import WizardStepper from './WizardStepper' +import WizardNavButtons from './WizardNavButtons' +import LivePreviewPanel from './LivePreviewPanel' +import SetupStep from './steps/SetupStep' +import AudienceStep from './steps/AudienceStep' +import MeasurementStep from './steps/MeasurementStep' +import ReviewStep from './steps/ReviewStep' + +const TOTAL_STEPS = 4 + +type CreateExperimentWizardProps = { + environmentId: string + projectId: number + onCreated: () => void +} + +const CreateExperimentWizard: FC = ({ + environmentId, + onCreated, + projectId, +}) => { + const [currentStep, setCurrentStep] = useState(0) + const [name, setName] = useState('') + const [hypothesis, setHypothesis] = useState('') + const [selectedFeature, setSelectedFeature] = useState( + null, + ) + const [completedSteps, setCompletedSteps] = useState>(new Set()) + + const [createExperiment, { isLoading: isSubmitting }] = + useCreateExperimentMutation() + + const isStep1Valid = useMemo( + () => + name.trim().length > 0 && + hypothesis.trim().length > 0 && + selectedFeature !== null, + [name, hypothesis, selectedFeature], + ) + + const canContinue = currentStep === 0 ? isStep1Valid : true + + const handleContinue = useCallback(() => { + if (currentStep < TOTAL_STEPS - 1) { + setCompletedSteps((prev) => new Set(prev).add(currentStep)) + setCurrentStep(currentStep + 1) + } + }, [currentStep]) + + const handleBack = useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1) + } + }, [currentStep]) + + const handleStepClick = useCallback( + (step: number) => { + if (completedSteps.has(step) || step < currentStep) { + setCurrentStep(step) + } + }, + [completedSteps, currentStep], + ) + + const doCreate = useCallback(async () => { + if (!selectedFeature) return + try { + await createExperiment({ + body: { + feature: selectedFeature.id, + hypothesis: hypothesis.trim(), + name: name.trim(), + }, + environmentId, + }).unwrap() + toast('Experiment created successfully') + onCreated() + } catch { + toast('Failed to create experiment', 'danger') + } + }, [ + createExperiment, + environmentId, + hypothesis, + name, + onCreated, + selectedFeature, + ]) + + const handleLaunch = useCallback(() => { + if (!selectedFeature) return + openConfirm({ + body: ( + + 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. + + ), + noText: 'Cancel', + onYes: doCreate, + title: 'Create experiment?', + yesText: 'Create', + }) + }, [selectedFeature, doCreate]) + + const renderStep = () => { + switch (currentStep) { + case 0: + return ( + + ) + case 1: + return + case 2: + return + case 3: + return ( + setCurrentStep(0)} + /> + ) + default: + return null + } + } + + return ( +
+ +
+ {renderStep()} + +
+ +
+ ) +} + +export default CreateExperimentWizard diff --git a/frontend/web/components/experiments/LivePreviewPanel.tsx b/frontend/web/components/experiments/LivePreviewPanel.tsx new file mode 100644 index 000000000000..27fc768e3bdb --- /dev/null +++ b/frontend/web/components/experiments/LivePreviewPanel.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react' +import ContentCard from 'components/base/grid/ContentCard' + +const LivePreviewPanel: FC = () => { + return ( +
+ +

+ Coming soon +

+
+
+ ) +} + +export default LivePreviewPanel diff --git a/frontend/web/components/experiments/VariationTable.scss b/frontend/web/components/experiments/VariationTable.scss new file mode 100644 index 000000000000..536b7d559352 --- /dev/null +++ b/frontend/web/components/experiments/VariationTable.scss @@ -0,0 +1,101 @@ +.variation-table { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + overflow: hidden; + + &__head { + display: flex; + padding: 12px 20px; + background: var(--color-surface-muted); + gap: 16px; + } + + &__th { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.02em; + + &--name, + &--desc { + flex: 1; + } + + &--value { + width: 100px; + } + } + + &__row { + display: flex; + padding: 16px 20px; + gap: 16px; + border-top: 1px solid var(--color-border-default); + align-items: center; + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-hover); + } + } + + &__cell { + &--name { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + } + + &--desc { + flex: 1; + min-width: 0; + word-break: break-word; + } + + &--value { + width: 120px; + flex-shrink: 0; + } + } + + &__dot { + width: 10px; + height: 10px; + border-radius: var(--radius-full); + flex-shrink: 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-muted); + padding: 2px 8px; + border-radius: var(--radius-sm); + } + + &__desc-text { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + line-height: 1.4; + } + + &__value-badge { + 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); + } +} diff --git a/frontend/web/components/experiments/VariationTable.tsx b/frontend/web/components/experiments/VariationTable.tsx new file mode 100644 index 000000000000..950b92009301 --- /dev/null +++ b/frontend/web/components/experiments/VariationTable.tsx @@ -0,0 +1,82 @@ +import { FC } from 'react' +import { MultivariateOption } from 'common/types/responses' +import './VariationTable.scss' + +const CONTROL_COLOUR = 'var(--green-500)' +const VARIATION_COLOUR = 'var(--purple-500)' + +type VariationTableProps = { + controlValue: string + variations: MultivariateOption[] +} + +const getVariationValue = (mv: MultivariateOption) => { + 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 '' +} + +const VariationTable: FC = ({ + controlValue, + variations, +}) => { + return ( +
+
+ + Name + + + Description + + + Value + +
+ +
+
+ + Control + control +
+
+ + Flag's base value — the baseline for comparison + +
+
+ {controlValue} +
+
+ + {variations.map((mv) => ( +
+
+ + + {mv.string_value || `Variation ${mv.id}`} + +
+
+ +
+
+ + {getVariationValue(mv)} + +
+
+ ))} +
+ ) +} + +export default VariationTable diff --git a/frontend/web/components/experiments/WizardNavButtons.tsx b/frontend/web/components/experiments/WizardNavButtons.tsx new file mode 100644 index 000000000000..f97d582a2f33 --- /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.scss b/frontend/web/components/experiments/WizardStepper.scss new file mode 100644 index 000000000000..385997c13a24 --- /dev/null +++ b/frontend/web/components/experiments/WizardStepper.scss @@ -0,0 +1,117 @@ +.wizard-sidebar { + display: flex; + flex-direction: column; + width: 220px; + flex-shrink: 0; +} + +.wizard-step { + display: flex; + gap: 20px; + + &--done { + cursor: pointer; + + .wizard-step__circle { + background: var(--color-surface-success); + border: 2px solid var(--green-500); + color: var(--color-text-success); + } + + .wizard-step__title { + color: var(--color-text-default); + } + } + + &--active { + .wizard-step__circle { + border: 2px solid var(--color-border-action); + color: var(--color-text-action); + } + + .wizard-step__title { + color: var(--color-text-default); + } + } + + &--upcoming { + .wizard-step__circle { + border: 2px solid var(--color-border-default); + color: var(--color-text-disabled); + } + + .wizard-step__title { + color: var(--color-text-disabled); + } + } + + &__left { + display: flex; + flex-direction: column; + align-items: center; + width: 32px; + flex-shrink: 0; + } + + &__circle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + flex-shrink: 0; + } + + &__number { + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-semibold); + } + + &__connector { + width: 2px; + flex: 1; + min-height: 24px; + background: var(--color-border-default); + } + + &__right { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0 24px; + flex: 1; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + &__title { + font-size: var(--font-h6-size); + font-weight: var(--font-weight-semibold); + } + + &__badge { + font-size: var(--font-caption-xs-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-success); + background: var(--color-surface-success); + padding: 3px 10px; + border-radius: var(--radius-full); + } + + &__subtitle { + font-size: var(--font-body-sm-size); + color: var(--color-text-disabled); + } + + &--active &__subtitle { + color: var(--color-text-secondary); + font-size: var(--font-body-size); + line-height: 1.5; + } +} diff --git a/frontend/web/components/experiments/WizardStepper.tsx b/frontend/web/components/experiments/WizardStepper.tsx new file mode 100644 index 000000000000..f415afa95585 --- /dev/null +++ b/frontend/web/components/experiments/WizardStepper.tsx @@ -0,0 +1,101 @@ +import { FC } from 'react' +import Icon from 'components/icons/Icon' +import './WizardStepper.scss' + +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 +} + +type StepStatus = 'done' | 'active' | 'upcoming' + +const getStatus = ( + index: number, + currentStep: number, + completedSteps: Set, +): StepStatus => { + if (completedSteps.has(index) && index !== currentStep) return 'done' + if (index === currentStep) return 'active' + return 'upcoming' +} + +const WizardStepper: FC = ({ + completedSteps, + currentStep, + onStepClick, +}) => { + return ( + + ) +} + +export default WizardStepper diff --git a/frontend/web/components/experiments/steps/AudienceStep.tsx b/frontend/web/components/experiments/steps/AudienceStep.tsx new file mode 100644 index 000000000000..1dcb2d56e80e --- /dev/null +++ b/frontend/web/components/experiments/steps/AudienceStep.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react' +import { IonIcon } from '@ionic/react' +import { peopleOutline } from 'ionicons/icons' +import ContentCard from 'components/base/grid/ContentCard' +import 'components/experiments/wizard.scss' + +const SAMPLE_PRESETS = [5, 10, 25, 50, 100] + +const AudienceStep: FC = () => { + return ( +
+ +

+ Define who is eligible for the experiment using attribute conditions. + Conditions are AND-joined. Leave empty to run on all identities in the + environment. Conditions are frozen at launch — later edits to + existing Segments cannot drift the experiment audience. +

+
+ +
+
All identities in this environment
+
+ No targeting conditions — every identity is eligible for the + experiment. Add a condition to filter the audience. +
+
+
+
+ + +

+ What percentage of eligible users enters the experiment? The rest keep + the flag's environment default and aren't part of the + result. +

+
+ {SAMPLE_PRESETS.map((pct) => ( +
+ {pct}% +
+ ))} +
+ Custom +
+
+
+
+ ) +} + +export default AudienceStep diff --git a/frontend/web/components/experiments/steps/MeasurementStep.tsx b/frontend/web/components/experiments/steps/MeasurementStep.tsx new file mode 100644 index 000000000000..b86b4ea01df5 --- /dev/null +++ b/frontend/web/components/experiments/steps/MeasurementStep.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react' +import Button from 'components/base/forms/Button' +import { IonIcon } from '@ionic/react' +import { addOutline, searchOutline } from 'ionicons/icons' +import ContentCard from 'components/base/grid/ContentCard' +import 'components/experiments/wizard.scss' + +const MeasurementStep: FC = () => { + return ( +
+ +
+
+ + +
+ +
+
+
+ ) +} + +export default MeasurementStep diff --git a/frontend/web/components/experiments/steps/ReviewStep.tsx b/frontend/web/components/experiments/steps/ReviewStep.tsx new file mode 100644 index 000000000000..86c1c1b7032a --- /dev/null +++ b/frontend/web/components/experiments/steps/ReviewStep.tsx @@ -0,0 +1,60 @@ +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 VariationTable from 'components/experiments/VariationTable' +import 'components/experiments/wizard.scss' + +type ReviewStepProps = { + name: string + hypothesis: string + selectedFeature: ProjectFlag | null + onEditSetup: () => void +} + +const ReviewStep: FC = ({ + hypothesis, + name, + onEditSetup, + selectedFeature, +}) => { + return ( +
+ + Edit + + } + > +
+ Name + {name} +
+ {hypothesis && ( +
+ Hypothesis + {hypothesis} +
+ )} + {selectedFeature && ( + <> +
+ Feature Flag + + {selectedFeature.name} + +
+ + + )} +
+
+ ) +} + +export default ReviewStep diff --git a/frontend/web/components/experiments/steps/SetupStep.tsx b/frontend/web/components/experiments/steps/SetupStep.tsx new file mode 100644 index 000000000000..45a6cf14500f --- /dev/null +++ b/frontend/web/components/experiments/steps/SetupStep.tsx @@ -0,0 +1,142 @@ +import { FC, useMemo } from 'react' +import { ProjectFlag } from 'common/types/responses' +import { useGetFeatureListQuery } from 'common/services/useProjectFlag' +import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' +import useDebouncedSearch from 'common/useDebouncedSearch' +import Utils from 'common/utils/utils' +import ContentCard from 'components/base/grid/ContentCard' +import VariationTable from 'components/experiments/VariationTable' +import 'components/experiments/wizard.scss' + +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 { getEnvironmentIdFromKey } = useProjectEnvironments(projectId) + const numericEnvId = getEnvironmentIdFromKey(environmentId) + + const { data: featureList, isLoading: isFeaturesLoading } = + useGetFeatureListQuery( + { + environmentId: String(numericEnvId ?? ''), + page: 1, + page_size: 50, + projectId, + search: search || undefined, + }, + { skip: !numericEnvId }, + ) + + const multivariateFeatures = useMemo(() => { + if (!featureList?.results) return [] + return featureList.results.filter((f) => f.type === 'MULTIVARIATE') + }, [featureList]) + + 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' + /> +
+ +
+ +