diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index cb8cb6f03fe0..ddecbc5e4db9 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -37,6 +37,18 @@ const defaultFlags = { 'perEnvironment': false, 'title': 'Code References', }, + 'data-warehouse': { + 'description': + 'Connect Snowflake, BigQuery, or Databricks to compute experiment metrics from your event data. Required for experimentation.', + 'docs': '/organisation/:organisationId/warehouse', + 'external': true, + 'image': '/static/images/integrations/data-warehouse.svg', + 'organisation': true, + 'perEnvironment': false, + 'project': true, + 'tags': ['analytics'], + 'title': 'Data Warehouse', + }, 'datadog': { 'description': 'Sends events to Datadog for when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production.', diff --git a/frontend/common/utils/motion.ts b/frontend/common/utils/motion.ts new file mode 100644 index 000000000000..78a2e138e2dd --- /dev/null +++ b/frontend/common/utils/motion.ts @@ -0,0 +1,143 @@ +// ============================================================================= +// Motion Presets — Reusable animation variants for the motion library +// Import these instead of defining raw values in components. +// +// Usage: +// import { motion, AnimatePresence } from 'motion/react' +// import { fadeIn, staggerContainer } from 'common/utils/motion' +// +// +// ... +// +// ============================================================================= + +import type { Variants } from 'motion/react' + +// ----------------------------------------------------------------------------- +// Fade +// ----------------------------------------------------------------------------- + +/** Fade in with optional delay. Default 250ms. */ +export const fadeIn = (delay = 0): Variants => ({ + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { delay, duration: 0.25, ease: [0.2, 0, 0.38, 0.9] }, + }, +}) + +/** Fade out. Matches --easing-exit. */ +export const fadeOut: Variants = { + exit: { + opacity: 0, + transition: { duration: 0.2, ease: [0.2, 0, 1, 0.9] }, + }, + visible: { opacity: 1 }, +} + +// ----------------------------------------------------------------------------- +// Slide +// ----------------------------------------------------------------------------- + +/** Slide in from right. 320ms, matches --easing-entrance. */ +export const slideInRight: Variants = { + exit: { + opacity: 0, + transition: { duration: 0.2, ease: [0.2, 0, 1, 0.9] }, + x: -40, + }, + hidden: { opacity: 0, x: 40 }, + visible: { + opacity: 1, + transition: { duration: 0.32, ease: [0.0, 0, 0.38, 0.9] }, + x: 0, + }, +} + +/** Slide in from below. Subtler, for inline content. */ +export const slideInUp: Variants = { + hidden: { opacity: 0, y: 12 }, + visible: { + opacity: 1, + transition: { duration: 0.25, ease: [0.0, 0, 0.38, 0.9] }, + y: 0, + }, +} + +// ----------------------------------------------------------------------------- +// Stagger +// ----------------------------------------------------------------------------- + +/** Container that staggers its children's entrance. */ +export const staggerContainer = (staggerDelay = 0.1): Variants => ({ + hidden: {}, + visible: { transition: { staggerChildren: staggerDelay } }, +}) + +/** Individual child item for use inside a stagger container. */ +export const staggerItem: Variants = { + hidden: { opacity: 0, y: 8 }, + visible: { + opacity: 1, + transition: { duration: 0.3, ease: [0.0, 0, 0.38, 0.9] }, + y: 0, + }, +} + +// ----------------------------------------------------------------------------- +// Spring / Bounce +// ----------------------------------------------------------------------------- + +/** Spring bounce — success checkmarks, icons appearing. */ +export const springBounce: Variants = { + hidden: { opacity: 0, scale: 0 }, + visible: { + opacity: 1, + scale: 1, + transition: { damping: 15, stiffness: 300, type: 'spring' }, + }, +} + +// ----------------------------------------------------------------------------- +// Shake +// ----------------------------------------------------------------------------- + +/** Horizontal shake — error states, validation failures. */ +export const shakeX: Variants = { + idle: { x: 0 }, + shake: { + transition: { duration: 0.4, ease: 'easeInOut' }, + x: [0, -8, 8, -4, 4, 0], + }, +} + +// ----------------------------------------------------------------------------- +// Badge / Chip +// ----------------------------------------------------------------------------- + +/** Badge slides down from above with fade. 250ms with 400ms delay. */ +export const badgeEntrance: Variants = { + hidden: { opacity: 0, y: -12 }, + visible: { + opacity: 1, + transition: { delay: 0.4, duration: 0.25, ease: [0.0, 0, 0.38, 0.9] }, + y: 0, + }, +} + +// ----------------------------------------------------------------------------- +// Page Transition (for AnimatePresence) +// ----------------------------------------------------------------------------- + +/** Crossfade between pages/states. Use with AnimatePresence mode="wait". */ +export const pageCrossfade: Variants = { + exit: { + opacity: 0, + transition: { duration: 0.15, ease: [0.2, 0, 1, 0.9] }, + }, + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { duration: 0.25, ease: [0.2, 0, 0.38, 0.9] }, + }, +} diff --git a/frontend/documentation/MotionPresets.stories.tsx b/frontend/documentation/MotionPresets.stories.tsx new file mode 100644 index 000000000000..a2ccfc6587c6 --- /dev/null +++ b/frontend/documentation/MotionPresets.stories.tsx @@ -0,0 +1,318 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import { motion, AnimatePresence } from 'motion/react' +import { + fadeIn, + slideInRight, + slideInUp, + staggerContainer, + staggerItem, + springBounce, + shakeX, + badgeEntrance, + pageCrossfade, +} from 'common/utils/motion' + +const meta: Meta = { + tags: ['autodocs'], + title: 'Design System/Motion Presets', +} +export default meta + +type Story = StoryObj + +const DemoBox = ({ + children, + style, +}: { + children: React.ReactNode + style?: React.CSSProperties +}) => ( +
+ {children} +
+) + +export const FadeIn: Story = { + decorators: [ + () => { + const [key, setKey] = useState(0) + return ( +
+ + + fadeIn() + + + {'variants={fadeIn()} initial="hidden" animate="visible"'} + +
+ ) + }, + ], +} + +export const SlideInRight: Story = { + decorators: [ + () => { + const [key, setKey] = useState(0) + return ( +
+ + + slideInRight — page transitions + +
+ ) + }, + ], +} + +export const SlideInUp: Story = { + decorators: [ + () => { + const [key, setKey] = useState(0) + return ( +
+ + + slideInUp — inline content + +
+ ) + }, + ], +} + +export const StaggeredList: Story = { + decorators: [ + () => { + const [key, setKey] = useState(0) + return ( +
+ + + {[ + 'Resolving hostname...', + 'Establishing TLS...', + 'Authenticating...', + 'Verifying schema...', + ].map((text) => ( + + {text} + + ))} + +
+ ) + }, + ], +} + +export const SpringBounce: Story = { + decorators: [ + () => { + const [key, setKey] = useState(0) + return ( +
+ + + + ✓ Success + + +
+ ) + }, + ], +} + +export const ShakeError: Story = { + decorators: [ + () => { + const [shaking, setShaking] = useState(false) + return ( +
+ + + + Error: Invalid credentials + + +
+ ) + }, + ], +} + +export const BadgeEntrance: Story = { + decorators: [ + () => { + const [key, setKey] = useState(0) + return ( +
+ + + Connected + +
+ ) + }, + ], +} + +export const PageCrossfade: Story = { + decorators: [ + () => { + const [page, setPage] = useState(0) + const pages = ['Page A', 'Page B', 'Page C'] + return ( +
+
+ {pages.map((label, i) => ( + + ))} +
+ + + + {pages[page]} — crossfade transition + + + +
+ ) + }, + ], +} diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index dc15a68e3164..87a029ba718e 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -85,7 +85,10 @@ const Integration: FC = (props) => { window.addEventListener('message', (event) => { if ( event.source === childWindow && - (event.data?.hasOwnProperty('installationId') || + (Object.prototype.hasOwnProperty.call( + event.data ?? {}, + 'installationId', + ) || event.data.installationId) ) { setWindowInstallationId(event.data.installationId) @@ -129,6 +132,75 @@ const Integration: FC = (props) => { activeIntegrations.length ) + const isInternalPath = typeof docs === 'string' && docs.startsWith('/') + const resolvedHref = isInternalPath + ? docs.replace( + ':organisationId', + String(AccountStore.getOrganisation()?.id ?? ''), + ) + : docs + + const renderAddCta = () => { + if (external && !isExternalInstallation) { + return ( + + Add Integration + + ) + } + if ( + external && + isExternalInstallation && + (windowInstallationId || props.githubMeta.hasIntegrationWithGithub) + ) { + return ( + + ) + } + if ( + external && + !props.githubMeta.hasIntegrationWithGithub && + isExternalInstallation + ) { + return ( + + ) + } + return ( + + ) + } + return (
@@ -168,57 +240,7 @@ const Integration: FC = (props) => { Delete Integration ))} - {showAdd && ( - <> - {external && !isExternalInstallation ? ( - - Add Integration - - ) : external && - isExternalInstallation && - (windowInstallationId || - props.githubMeta.hasIntegrationWithGithub) ? ( - - ) : external && - !props.githubMeta.hasIntegrationWithGithub && - isExternalInstallation ? ( - - ) : ( - - )} - - )} + {showAdd && renderAddCta()}
@@ -267,6 +289,7 @@ const IntegrationList: FC = (props) => { useEffect(() => { fetch() fetchGithubIntegration() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const fetchGithubIntegration = () => { diff --git a/frontend/web/components/charts/LineChart.tsx b/frontend/web/components/charts/LineChart.tsx new file mode 100644 index 000000000000..219f25ae729c --- /dev/null +++ b/frontend/web/components/charts/LineChart.tsx @@ -0,0 +1,122 @@ +import React, { FC } from 'react' +import { + CartesianGrid, + Legend, + Line, + LineChart as RawLineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { colorTextSecondary } from 'common/theme/tokens' + +export type LineChartDataPoint = { + day: string | number +} & Record + +type LineChartProps = { + data: LineChartDataPoint[] + series: string[] + colorMap: Record + /** + * Optional dataKey → display name map, used for the legend and tooltip when + * dataKeys differ from the label users should see. + */ + seriesLabels?: Record + showLegend?: boolean + xAxisLabel?: string + yAxisFormatter?: (value: number) => string + tooltipValueFormatter?: (value: number) => string + tooltipLabelFormatter?: (label: string | number) => string + height?: number +} + +const LineChart: FC = ({ + colorMap, + data, + height = 320, + series, + seriesLabels, + showLegend = true, + tooltipLabelFormatter, + tooltipValueFormatter, + xAxisLabel, + yAxisFormatter, +}) => { + return ( + + + + + + tooltipValueFormatter(value) + : undefined + } + labelFormatter={tooltipLabelFormatter} + contentStyle={{ + background: 'var(--color-surface-default)', + border: '1px solid var(--color-border-default)', + borderRadius: 'var(--radius-md)', + fontSize: 12, + }} + /> + {showLegend && ( + + seriesLabels?.[String(value)] ?? String(value) + } + /> + )} + {series.map((label, index) => ( + + ))} + + + ) +} + +LineChart.displayName = 'LineChart' +export default LineChart diff --git a/frontend/web/components/experiments-v2/CreateExperimentPage.scss b/frontend/web/components/experiments-v2/CreateExperimentPage.scss new file mode 100644 index 000000000000..0ac91952e5ab --- /dev/null +++ b/frontend/web/components/experiments-v2/CreateExperimentPage.scss @@ -0,0 +1,12 @@ +.create-experiment-page { + display: flex; + flex-direction: column; + gap: 24px; + padding: 32px 48px; + min-height: 100vh; + + &__divider { + height: 1px; + background: var(--color-border-default); + } +} diff --git a/frontend/web/components/experiments-v2/CreateExperimentPage.tsx b/frontend/web/components/experiments-v2/CreateExperimentPage.tsx new file mode 100644 index 000000000000..3439fba1ce75 --- /dev/null +++ b/frontend/web/components/experiments-v2/CreateExperimentPage.tsx @@ -0,0 +1,330 @@ +import React, { FC, useCallback, useMemo, useState } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import WizardLayout from './wizard/WizardLayout' +import WizardSidebar from './wizard/WizardSidebar' +import WizardHeader from './wizard/WizardHeader' +import WizardNavButtons from './wizard/WizardNavButtons' +import ExperimentDetailsStep from './steps/ExperimentDetailsStep' +import SelectMetricsStep from './steps/SelectMetricsStep' +import FlagVariationsStep from './steps/FlagVariationsStep' +import AudienceStep from './steps/AudienceStep' +import ReviewLaunchStep from './steps/ReviewLaunchStep' +import { buildExperimentArms, splitEvenly } from './steps/AudienceStep' +import { + EXPERIMENT_WIZARD_STEPS, + ExperimentWizardState, + Metric, + MetricRole, + MIN_VARIATIONS_FOR_EXPERIMENT, + MOCK_FLAGS, + MOCK_METRICS, + MOCK_SEGMENTS, + MOCK_VARIATIONS, + Variation, +} from './types' +import './CreateExperimentPage.scss' + +const INITIAL_FLAG = MOCK_FLAGS[0] +const INITIAL_ARMS = buildExperimentArms( + INITIAL_FLAG.controlValue, + INITIAL_FLAG.variations, +) + +const toISODate = (d: Date) => d.toISOString().slice(0, 10) +const DEFAULT_START = new Date() +const DEFAULT_END = new Date(DEFAULT_START) +DEFAULT_END.setDate(DEFAULT_END.getDate() + 14) + +const INITIAL_STATE: ExperimentWizardState = { + audience: { + samplePercentage: 100, + segmentId: 'seg-3', + weights: splitEvenly(INITIAL_ARMS.map((a) => a.id)), + }, + controlValue: INITIAL_FLAG.controlValue, + currentStep: 0, + details: { + endDate: toISODate(DEFAULT_END), + hypothesis: + 'Redesigning the checkout button with a clearer CTA will increase conversion rates by at least 15% within 30 days', + name: 'Checkout Button Redesign', + startDate: toISODate(DEFAULT_START), + }, + featureFlagId: INITIAL_FLAG.value, + metrics: [ + MOCK_METRICS[0], + { ...MOCK_METRICS[1], role: 'secondary' }, + { ...MOCK_METRICS[2], role: 'guardrail' }, + ], + variations: MOCK_VARIATIONS, +} + +const TOTAL_STEPS = EXPERIMENT_WIZARD_STEPS.length + +const CreateExperimentPage: FC = () => { + const history = useHistory() + const { environmentId, projectId } = useParams<{ + projectId?: string + environmentId?: string + }>() + const experimentsUrl = + projectId && environmentId + ? `/project/${projectId}/environment/${environmentId}/experiments` + : '/experiments' + const [state, setState] = useState(INITIAL_STATE) + + const goToStep = useCallback((step: number) => { + setState((prev) => ({ ...prev, currentStep: step })) + }, []) + + const handleBack = useCallback(() => { + goToStep(Math.max(0, state.currentStep - 1)) + }, [state.currentStep, goToStep]) + + const handleLaunch = useCallback(() => { + const segment = MOCK_SEGMENTS.find( + (s) => s.value === state.audience.segmentId, + ) + const flag = MOCK_FLAGS.find((f) => f.value === state.featureFlagId) + const audienceLabel = segment?.label ?? 'all users in the environment' + openConfirm({ + body: ( + + This will start serving variations of{' '} + {flag?.label ?? 'this flag'} to{' '} + {state.audience.samplePercentage}% of{' '} + {audienceLabel}. You can pause or stop the experiment + at any time. + + ), + onYes: () => { + toast(`Experiment "${state.details.name}" launched`) + history.push(experimentsUrl) + }, + title: 'Launch experiment?', + yesText: 'Launch', + }) + }, [state, history, experimentsUrl]) + + const handleContinue = useCallback(() => { + if (state.currentStep < TOTAL_STEPS - 1) { + goToStep(state.currentStep + 1) + } else { + handleLaunch() + } + }, [state.currentStep, goToStep, handleLaunch]) + + const handleCancel = useCallback(() => { + history.goBack() + }, [history]) + + const handleToggleMetric = useCallback((metric: Metric) => { + setState((prev) => { + const exists = prev.metrics.find((m) => m.id === metric.id) + if (exists) { + return { + ...prev, + metrics: prev.metrics.filter((m) => m.id !== metric.id), + } + } + const hasPrimary = prev.metrics.some((m) => m.role === 'primary') + const role: MetricRole = hasPrimary ? 'secondary' : 'primary' + return { ...prev, metrics: [...prev.metrics, { ...metric, role }] } + }) + }, []) + + const handleSetMetricRole = useCallback( + (metricId: string, role: MetricRole) => { + setState((prev) => ({ + ...prev, + metrics: prev.metrics.map((m) => + m.id === metricId ? { ...m, role } : m, + ), + })) + }, + [], + ) + + const isCurrentStepValid = useMemo(() => { + if (state.currentStep === 0) { + return ( + state.details.name.trim().length > 0 && + state.details.hypothesis.trim().length > 0 + ) + } + if (state.currentStep === 1) { + return ( + !!state.featureFlagId && + state.variations.length >= MIN_VARIATIONS_FOR_EXPERIMENT + ) + } + if (state.currentStep === 3) { + const sum = (state.audience.weights ?? []).reduce( + (s, w) => s + w.weight, + 0, + ) + return sum === 100 && state.audience.samplePercentage > 0 + } + return true + }, [ + state.currentStep, + state.details.name, + state.details.hypothesis, + state.featureFlagId, + state.variations.length, + state.audience.samplePercentage, + state.audience.weights, + ]) + + const stepsWithSummary = EXPERIMENT_WIZARD_STEPS.map((step, i) => { + if (i >= state.currentStep) return step + + let completeSummary: string | undefined + switch (i) { + case 0: + completeSummary = state.details.name || undefined + break + case 1: { + const armCount = state.variations.length + 1 + completeSummary = `${armCount} arm${armCount === 1 ? '' : 's'}` + break + } + case 2: { + const pCount = state.metrics.filter((m) => m.role === 'primary').length + const sCount = state.metrics.filter( + (m) => m.role === 'secondary', + ).length + const gCount = state.metrics.filter( + (m) => m.role === 'guardrail', + ).length + const parts = [ + `${pCount} primary`, + `${sCount} secondary`, + gCount > 0 ? `${gCount} guardrail` : null, + ].filter(Boolean) + completeSummary = parts.join(' · ') + break + } + case 3: { + const splitParts = (state.audience.weights ?? []) + .filter((w) => w.weight > 0) + .map((w) => `${w.weight}%`) + const split = splitParts.length > 0 ? splitParts.join('/') : null + const audienceLabel = state.audience.segmentId + ? MOCK_SEGMENTS.find((s) => s.value === state.audience.segmentId) + ?.label ?? 'segment' + : 'All users' + const summaryParts = [ + audienceLabel, + `${state.audience.samplePercentage}%`, + split, + ].filter(Boolean) + completeSummary = summaryParts.join(' · ') + break + } + default: + break + } + return { ...step, completeSummary } + }) + + const renderStepContent = () => { + switch (state.currentStep) { + case 0: + return ( + setState((prev) => ({ ...prev, details }))} + /> + ) + case 1: + return ( + + setState((prev) => ({ ...prev, featureFlagId: flagId })) + } + onControlValueChange={(controlValue) => + setState((prev) => ({ ...prev, controlValue })) + } + onVariationsChange={(variations: Variation[]) => + setState((prev) => { + const arms = buildExperimentArms(prev.controlValue, variations) + return { + ...prev, + audience: { + ...prev.audience, + weights: splitEvenly(arms.map((a) => a.id)), + }, + variations, + } + }) + } + /> + ) + case 2: + return ( + + ) + case 3: { + const flag = + MOCK_FLAGS.find((f) => f.value === state.featureFlagId) ?? null + return ( + setState((prev) => ({ ...prev, audience }))} + /> + ) + } + case 4: + return + default: + return null + } + } + + return ( +
+ + +
+ + + } + > + {renderStepContent()} + + + +
+ ) +} + +CreateExperimentPage.displayName = 'CreateExperimentPage' +export default CreateExperimentPage diff --git a/frontend/web/components/experiments-v2/EXPERIMENTATION_DESIGN.md b/frontend/web/components/experiments-v2/EXPERIMENTATION_DESIGN.md new file mode 100644 index 000000000000..7ed02b1292aa --- /dev/null +++ b/frontend/web/components/experiments-v2/EXPERIMENTATION_DESIGN.md @@ -0,0 +1,440 @@ +# Experiments v2 — Design Proposal + +Presentation notes for the Experiments v2 prototype. Explains the design +choices, how they map onto Flagsmith's data model, and the references that +back each decision. + +## Context + +Flagsmith already ships multi-variant flags, segment overrides, and identity +targeting — everything you need to run an experiment. What's missing is the +**workflow**: a guided way to set up a controlled experiment, pick metrics, +split traffic, and read results. Experiments v2 fills that gap as a +first-class product concept on top of the existing primitives. + +## Scope + +This prototype is **frontend-only**. The wizard is fully interactive with +mock data; no backend endpoints, no real metrics pipeline, no statistical +engine. See `NOTES.md` for the deferred items that require backend work +(sample-size calculator, metric baseline data, segment traffic tracking). + +The prototype covers: + +- Create Experiment wizard (5 steps) +- Experiments list + Metrics library (tabs) +- Experiment results page (comparison table + trend chart) + +It does **not** cover: + +- Post-launch experiment state management (pause/stop/restart) +- Real sample-size or power calculations +- Historical metric data or baselines +- Randomisation-unit override (we assume identity-based bucketing) + +## Vocabulary + +The audience of an experiment has three layers, each with its own term. +Segments stay a Flagsmith primitive — they just stop pulling double duty. + +| Term we use | What it means | Notes | +|---|---|---| +| **Audience** | The whole step / concept: who's eligible, how many of them are in, and how they split | LD calls this "Audience allocation" | +| **Targeting** (optional **Segment** filter) | Eligibility filter. Users matching the segment are eligible; everyone else is excluded. Empty = all identities in the environment | Segments stay a normal Flagsmith primitive — they just don't define the experiment audience by themselves | +| **Sample size** | Percentage of eligible users actually sampled into the experiment. The rest see the flag's environment default | LD: "Percent of users in this experiment" | +| **Variation split** | Among the sampled users, the per-arm weight distribution. Control takes a slot. Sum to 100 | LD: "Variation split" | +| **Variation** / **Control value** | Variation = an entry in the flag's variation list; control value = a field on the flag itself | Existing Flagsmith concepts | +| **Guardrail metric** | A safety-check role separate from primary/secondary | See [Metric roles](#metric-roles) | + +Internally we use `arm` as jargon for "control or variation" when doing +weight math, but user-facing copy stays as "variation". + +### Why segments aren't the audience + +The earlier prototype used a segment as the experiment's audience: pick +one segment, set per-arm weights, done. That conflated **targeting** +(property-based filter) with **audience allocation** (random sample of +eligible users). Two concrete failure modes: + +1. **No segment? No experiment.** Want to A/B test on the whole user + base? You'd need a fake "All users" segment. Segments are + property-based, not "everyone". +2. **No way to ramp.** Want to start at 5% and ramp up? You'd need a + property-based segment that captures 5% of users — segments aren't + random samplers. + +Splitting the concept into three layers (Targeting / Sample size / +Variation split) fixes both: targeting is *optional*, sampling is *its +own knob*, and split is *just the variation distribution*. + +## Data model + +An experiment is a **first-class record** that holds the audience +configuration plus the variation weights. The audience is layered — +optional segment filter, sample percentage, and per-arm weights — and +each layer has a backend equivalent. + +``` +Experiment { + // Audience + targeting_segment: | null // null = all identities in the environment + sample_percentage: 0–100 // fraction of eligible users sampled in + weights: [ + { value: , weight: X }, + { value: , weight: Y }, + { value: , weight: Z }, + ] // X + Y + Z = 100 + + // Forward-designed for later iterations + outside_variation: // served to eligible-but-not-sampled users + mutual_exclusion_group: null // layer ID; null means no exclusion +} +``` + +**Implications:** + +1. **Targeting is optional.** No segment selected = the eligible pool is + every identity in the environment. This is the default. +2. **Sample size is independent of variation split.** Setting sample_size + to 10% means 10% of eligible identities are in the experiment, and the + variation weights split *those* users. The other 90% see the flag's + environment default. +3. **Weights must sum to 100% across the sampled pool**, including + control. Control is still a field on the flag, not a variation — + the wizard treats it as one of the weight slots. +4. **One experiment per (segment, flag) pair.** If the chosen segment + already has an override on the flag, the wizard shows a conflict + banner. Existing overrides on other segments are unaffected; priority + ordering decides who wins for users matching multiple segments. +5. **Assignment requires two hash dimensions.** Hash 1 (`experiment_id + + identity`) decides "in or out of the experiment". Hash 2 + (`experiment_id + identity` with a salt) decides which variation. This + keeps "% in experiment" stable when you change the variation split, + and vice versa. + +### Forward-designed fields (`outside_variation`, `mutual_exclusion_group`) + +The data model includes two more fields the v1 UI doesn't surface: + +| Field | v1 default | When it lights up | +|---|---|---| +| `outside_variation` | flag's environment default | Let teams pick which variation eligible-but-not-sampled users see, decoupled from the flag's default. Useful when the experiment changes the flag default or when ramping without rolling back the default. | +| `mutual_exclusion_group` | `null` | Group experiments into a "layer" so a user is in at most one experiment per layer. Prevents traffic overlap when several experiments target the same audience. | + +Both stay inert until the UI exposes them. Including them up front +avoids a future schema migration on a live data model. + +## Wizard flow + +Five steps, in this order: + +1. **Experiment Details** — name, hypothesis (required), start and end dates +2. **Flag & Variations** — pick a multi-variant flag, view its variations (read-only) +3. **Select Metrics** — primary, secondary, guardrail roles +4. **Audience** — optional segment filter, sample size, variation split +5. **Review & Launch** — full summary with edit shortcuts + +### Why Details first, not Flag first + +Writing the hypothesis before picking the flag forces users to think about +*what they're trying to learn* before getting lost in config. This is the +LaunchDarkly pattern and Kohavi's recommendation in *Trustworthy Online +Controlled Experiments*. + +**Counter-argument:** the wizard's shape depends on the flag (variation +count → arm weights), so Flag-first could feel more natural. We went +Details-first for the intent-setting benefit; the downside is users +occasionally edit back when they realise their flag choice changes the arm +count. + +### Why variations are read-only + +Variations live on the flag, not the experiment. Letting users add or +remove variations from inside the wizard would mutate the flag config +silently. Instead we show the existing variations and block progression if +the flag has fewer than one (the minimum for a two-arm experiment: control ++ one variation). + +## Metric roles + +We support three roles: + +| Role | Purpose | Stat question | +|---|---|---| +| **Primary** | The metric the experiment's verdict is based on | "Did the change improve this?" | +| **Secondary** | Other effects we want to observe | "What else moved?" | +| **Guardrail** | Metrics we must not regress, even if primary wins | "Did anything I care about break?" | + +### Why three roles, not two + +**Guardrails are a distinct axis** — not a ranking of importance, but a +different question entirely. A guardrail can be a metric that has nothing +to do with what the experiment is testing (e.g. page latency on a checkout +button redesign). Making it a separate role **forces the user to think +about safety**, not just optimisation. + +This is the convention in rigorous experimentation platforms: +- **Statsig** — dedicated *Guardrail Metrics* tier +- **Eppo** — first-class guardrail role +- **GrowthBook** — *Guardrail metrics* separate from *Goal metrics* +- **Optimizely** — monitoring/impact metrics serve the same role +- **Airbnb, Netflix, Microsoft ExP** — internal platforms all surface + guardrails (sometimes called "tripwires" or "health metrics") + +LaunchDarkly rolls this into secondary metrics today. We're taking the more +rigorous convention — backed by the industry textbook. + +### Soft warning on multi-primary + +When a user picks more than one primary metric, we surface a warning about +the multiple-comparisons problem without blocking. Best practice is one +primary; the warning explains why and invites the user to demote the rest. + +## Data collection — how metrics get measured + +The prototype mocks all metric values, but the architecture question is the +one the demo audience will ask first. Three production-ready approaches, +and what Flagsmith is set up to do. + +### Option A — SDK-based event ingestion (what LaunchDarkly does) + +Customer calls `flagsmith.track('checkout_completed', { value: 49.99, +identity: userId })` from their app. Flagsmith SDKs ship events to a +collection endpoint. Flagsmith's backend joins each event with the flag +evaluation the user saw (attribution), aggregates per arm, surfaces the +roll-up on the results page. + +- **Pros**: simplest onboarding for customers — no external tooling needed. +- **Cons**: biggest backend lift — we'd need an event ingestion service, + time-series storage, attribution pipeline, and aggregation. Doable on top + of the existing Edge/Task Processor infrastructure. + +### Option B — Warehouse-native (what this prototype shows) + +Events already live in the customer's data warehouse (Snowflake, BigQuery, +Databricks). Customer defines a metric by pointing Flagsmith at a warehouse +table + event name (or value column) + optional filter. Flagsmith pushes +flag-evaluation records into the same warehouse and runs aggregation +queries to compute per-arm results. + +This is the path shown in the current mocks: +- `web/components/warehouse/WarehousePage.tsx` — connection config + (Snowflake live; BigQuery/Databricks marked "Coming Soon") +- `ConnectionStats` mock shows `flagEvaluations24h: 1.28M` + + `customEvents24h: 84K` flowing into the connected warehouse +- Metric Library's `CreateMetricForm` includes a **Data Source** section + mapping each metric to `{warehouse, table, eventName | valueColumn, + filter}` +- Each mock metric in `MOCK_METRICS` carries a `source` that renders + inline in the library (e.g. *"EVENTS · checkout_completed"*) + +**Pros**: cheapest for Flagsmith to build — the customer's warehouse does +the heavy aggregation. Customer owns their data. + +**Cons**: requires the customer to have a warehouse. Not viable for +smaller customers running SaaS without a data pipeline. + +**Competitors that ship this model**: Eppo (primary), GrowthBook +Enterprise, Statsig (warehouse mode). + +### Option C — Analytics tool integration + +Metric definition lives in the customer's existing analytics tool (Segment, +Amplitude, Mixpanel, Heap, Google Analytics). Flagsmith pulls aggregates +via those tools' APIs and attributes by variation. + +- **Pros**: leverages what customers already use; fastest to value for + teams that have analytics but not a warehouse. +- **Cons**: every tool's API is different; rate limits, latency, and + attribution edge cases multiply per integration. + +### What the prototype demonstrates + +This build-out shows **Option B (warehouse-native)** end-to-end as mock UI: + +1. `Data Warehouse` page under the organisation nav — confirm the + connection is live, see stats on event throughput +2. `Metrics` library — each metric shows its source mapping + (`EVENTS · checkout_completed`, `TRANSACTIONS · amount_usd WHERE + status = 'complete'`, etc.) +3. Create / Edit Metric — a **Data Source** section maps the metric to the + warehouse table + event/column + optional filter +4. Experiment Results — per-arm numbers that (in production) would come + from aggregation queries against the warehouse + +### Recommended sequencing + +If this were going from prototype to production: + +1. **First**: ship warehouse-native (Option B) — lowest infra risk, serves + the experimentation-mature segment. The mocks in this repo are a + working starting point. +2. **Then**: add SDK `track()` (Option A) so customers without a warehouse + can still run experiments. Reuses the existing SDK analytics pipeline. +3. **Later**: selective analytics integrations (Option C) — Segment first + because it's the universal fanout layer for the others. + +Captured separately in `NOTES.md`: the **sample-size calculator** and +**guardrail semantic-flip** both depend on the collection pipeline above +being real — they're the reason this section exists as design intent, not +just a demo touch. + +## Traffic split mechanics + +Weights are edited via number inputs (0–100 each). When a user changes one +arm, the remaining weight is **redistributed proportionally** across the +others so the sum always lands on exactly 100. + +- With 2 arms: trivial — the other arm gets `100 − new`. +- With 3+ arms: delta is distributed proportionally based on current + weights, preserving the ratio between the unchanged arms. +- Integer rounding: a largest-fractional-part pass ensures the sum is + always exactly 100, never 99 or 101. + +An arm at 0 stays at 0 during rebalancing (a zero-weight arm is "excluded +by intent"). Users hit **Split evenly** to reset. + +## Safety & conflict detection + +- **Conflict banner** — when the selected segment already has an override + on the target flag, we surface a red banner directly under the segment + dropdown with resolution paths ("pick a different segment, or remove the + existing override on the Features page first"). +- **Guardrail metrics** — see above. +- **Launch confirmation modal** — no accidental launches. The modal names + both the flag and segment explicitly. + +## Presentation walkthrough + +Suggested narrative order for the live demo. Every step below has a +matching URL or shortcut so you don't have to click through the full +flow when you want to land on a specific state. + +### 1 — Frame the problem (30s) + +Open `/project/:id/environment/:id/experiments` (empty-ish or with mock +list). Motivate: Flagsmith has flags, segments, and identity targeting +— everything to *run* an experiment, but no workflow to *set one up*. +This branch adds that. + +### 2 — Data collection story (45s) + +Open **Organisation Integrations** → show the **Data Warehouse** card +alongside Jira, Grafana, etc. Click **Add Integration**. + +Use `?demo=1&state=empty` on the warehouse URL for the first landing. +Then cycle the switcher to narrate: + +- **Empty** → "no warehouse connected" +- **Configuring** → the connection form, call out the two-button Test + / Connect pattern (Test = verify only; Connect = commit) +- **Testing** → the 4-step Connecting animation +- **Connected** → live-stats card with 24h flag evaluations + custom + events flowing through, connection details grid +- **Error** → the auth-failed state with resolution paths + +Anchor: *"this is where experiment metrics come from — the warehouse +does the aggregation, Flagsmith reads the result."* Pair with the +three-approach framing from the Data collection section above. + +### 3 — Metrics library (45s) + +Back to the experiments nav → **Metrics**. Point out: + +- Each metric shows its warehouse source inline (`EVENTS · + checkout_completed`, `TRANSACTIONS · amount_usd WHERE status = + 'complete'`, etc.) — the direct handoff from the warehouse story +- Role system (primary / secondary / guardrail) — highlight this is + where Flagsmith differs from LaunchDarkly +- Create Metric → walk through measurement type cards, direction + picker, data source section + +### 4 — Create Experiment wizard (90s) + +From the Experiments list, **Create Experiment**. Walk the 5 steps: + +1. **Details** — name, hypothesis (required, with a real-example + placeholder), default dates (+14 days from today) +2. **Flag** — multi-variant flag picker; read-only variations table. + Optional detour: pick `homepage_hero_redesign` (0 variations) to + show the blocking banner +3. **Metrics** — pre-selected Conversion Rate (primary) + Revenue per + User (secondary) + Page Load Time (guardrail). Click one to show + the three-role segmented control. Add a second primary to surface + the soft multi-primary warning +4. **Audience** — three sub-blocks: Targeting (leave empty for "all + users", or pick Premium Tier on flag-1 to trigger the inline + conflict banner), Sample size (toggle 100 → 10 to show the dial + independently from the variation split), Variation split (play + with the auto-balancing weights — change one, watch the others + rebalance proportionally) +5. **Review & Launch** — full summary with per-section edit links. + Click **Launch** → confirmation modal → toast + redirect to list + +### 5 — Results page (45s) + +From the list, click a running experiment. Narrate top-down: + +- Stat cards + recommendation callout +- Metrics comparison table — call out the primary-row emphasis, + guardrail badge, zero-centred lift bars +- Scroll to the trend chart — metric selector + control vs treatment + line chart + +Anchor: *"this is the loop closed — metrics defined in the library, +data streaming through the warehouse, results computed per arm."* + +### 6 — What's deferred (30s) + +Short list from the *What we're deferring* section. Keep it honest — +this is a workflow prototype, not a production experimentation +platform. Backend work is the next sprint. + +## What we're deferring + +Captured in `NOTES.md`: + +- **Sample-size / duration calculator** — needs backend infra (cached + segment counts + metric baseline data) and a new MDE input +- **Demo URL param for empty metrics** — for showcasing the empty state + without clearing the mock catalog + +Not yet captured but known gaps: + +- Post-launch experiment management (pause, stop, re-launch) +- Flag collision detection (flag already has a running experiment) +- Randomisation unit selector (currently assumes identity-based) +- Save-as-draft +- Test-identity preview ("would user X be in this experiment?") + +## References + +### Academic / industry + +- Kohavi, R., Tang, D., & Xu, Y. (2020). *Trustworthy Online Controlled + Experiments: A Practical Guide to A/B Testing.* Cambridge University + Press. **Chapter 7 covers guardrail metrics explicitly.** This is the + definitive industry textbook. +- Kohavi, R. et al. (2019). *Top Challenges from the first Practical Online + Controlled Experiments Summit.* SIGKDD. Documents shared practices + across Airbnb, Amazon, Google, LinkedIn, Microsoft, Netflix. +- Kohavi, R. & Thomke, S. (2017). *The Surprising Power of Online + Experiments.* Harvard Business Review. + +### Vendor documentation + +- **Statsig** — Guardrail Metrics as a dedicated metric tier +- **Eppo** — Guardrail metrics as a first-class role +- **GrowthBook** — open source; guardrails separate from goal metrics +- **Optimizely** — impact / monitoring metrics serve the same role +- **LaunchDarkly** — health metrics are informal, not a distinct UI role + today (gap Flagsmith can close) + +### Engineering blogs + +- Netflix Tech Blog — multiple posts on the XP experimentation platform, + including guardrails for streaming quality and error rates +- Airbnb Engineering — ERF (Experimentation Reporting Framework) papers + mention guardrail/tripwire metrics +- Booking.com — experimentation culture posts calling out guardrails for + revenue metrics diff --git a/frontend/web/components/experiments-v2/ExperimentResultsPage.scss b/frontend/web/components/experiments-v2/ExperimentResultsPage.scss new file mode 100644 index 000000000000..e43bab52a698 --- /dev/null +++ b/frontend/web/components/experiments-v2/ExperimentResultsPage.scss @@ -0,0 +1,187 @@ +.experiment-results-page { + display: flex; + flex-direction: column; + gap: 24px; + padding: 32px 32px 24px; + + &__header { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__title-row { + display: flex; + align-items: center; + gap: 16px; + } + + &__title { + font-size: var(--font-h4-size); + font-weight: var(--font-weight-bold); + color: var(--color-text-default); + margin: 0; + } + + &__action-row { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__subtitle { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + } + + &__actions { + display: flex; + gap: 8px; + + .btn svg { + margin-right: 4px; + } + } + + // Timeline progress + &__timeline { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + } + + &__timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__timeline-label { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__timeline-value { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + } + + &__timeline-track { + height: 6px; + background: var(--color-surface-muted); + border-radius: var(--radius-full); + overflow: hidden; + } + + &__timeline-fill { + height: 100%; + background: var(--color-surface-action); + border-radius: var(--radius-full); + transition: width var(--duration-slow) var(--easing-standard); + } + + // Stat cards + &__cards { + display: flex; + flex-wrap: wrap; + gap: 16px; + } + + // Recommendation callout + &__recommendation { + display: flex; + gap: 12px; + padding: 16px 20px; + background: var(--color-surface-success); + border: 1px solid var(--color-border-success); + border-radius: var(--radius-lg); + + svg { + color: var(--color-icon-success); + flex-shrink: 0; + margin-top: 2px; + } + } + + &__recommendation-content { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__recommendation-title { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-success); + } + + &__recommendation-text { + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + line-height: 1.5; + } + + // Table section + &__table-section { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__section-title { + font-size: var(--font-h6-size); + font-weight: var(--font-weight-bold); + color: var(--color-text-default); + margin: 0; + } + + &__subsection-title { + margin: 16px 0 0 0; + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + // Config summary + &__config { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + } + + &__config-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + } + + &__config-label { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__config-value { + font-size: var(--font-body-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + + &--mono { + font-family: var(--font-family); + } + } +} diff --git a/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx b/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx new file mode 100644 index 000000000000..513c944df8c1 --- /dev/null +++ b/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx @@ -0,0 +1,148 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import StatusBadge from './shared/StatusBadge' +import ExperimentStatCard from './shared/ExperimentStatCard' +import MetricsComparisonTable from './shared/MetricsComparisonTable' +import MetricsTrendChart from './shared/MetricsTrendChart' +import { MOCK_EXPERIMENT_RESULT } from './types' +import './ExperimentResultsPage.scss' + +const ExperimentResultsPage: FC = () => { + const result = MOCK_EXPERIMENT_RESULT + const progressPercent = Math.round( + (result.daysCurrent / result.daysTotal) * 100, + ) + + return ( +
+
+
+

{result.name}

+ +
+ +
+ + Primary metric: {result.primaryMetric} · Last updated{' '} + {result.lastUpdated} + +
+ +
+
+ + {/* Timeline progress */} +
+
+ + Experiment Timeline + + + Day {result.daysCurrent} of {result.daysTotal} + +
+
+
+
+
+
+ +
+ + + + +
+ + {/* Recommendation callout */} +
+ +
+ + Recommendation + + + Treatment B is outperforming Control with 94.2% probability of being + the best variant. Consider rolling out Treatment B to 100% of + traffic after the experiment concludes on day {result.daysTotal}. + +
+
+ +
+

+ Metrics Comparison +

+ + +

+ Trend over time +

+ +
+ + {/* Experiment config summary */} +
+

+ Experiment Configuration +

+
+
+ Type + + A/B Test + +
+
+ + Feature Flag + + + checkout_button_redesign + +
+
+ + Segments + + + All Users + +
+
+ + Traffic + + + 50% / 50% + +
+
+
+
+ ) +} + +ExperimentResultsPage.displayName = 'ExperimentResultsPage' +export default ExperimentResultsPage diff --git a/frontend/web/components/experiments-v2/ExperimentsListPage.scss b/frontend/web/components/experiments-v2/ExperimentsListPage.scss new file mode 100644 index 000000000000..4207473482a1 --- /dev/null +++ b/frontend/web/components/experiments-v2/ExperimentsListPage.scss @@ -0,0 +1,107 @@ +.experiments-list-page { + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + h3 { + margin: 0; + } + } + + &__controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + gap: 16px; + } + + &__tabs { + display: flex; + gap: 4px; + background: var(--color-surface-subtle); + border-radius: var(--radius-md); + padding: 3px; + } + + &__tab { + padding: 6px 14px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--duration-fast) var(--easing-standard); + + &:hover { + color: var(--color-text-default); + } + + &--active { + background: var(--color-surface-default); + color: var(--color-text-default); + box-shadow: var(--shadow-sm); + } + } + + &__search { + min-width: 240px; + } + + &__table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + overflow: hidden; + + th { + padding: 10px 16px; + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + background: var(--color-surface-subtle); + text-align: left; + border-bottom: 1px solid var(--color-border-default); + } + + td { + padding: 14px 16px; + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + border-bottom: 1px solid var(--color-border-default); + } + + tbody tr:last-child td { + border-bottom: none; + } + } + + &__row { + cursor: pointer; + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-hover); + } + } + + &__flag-name { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + background: var(--color-surface-subtle); + padding: 2px 6px; + border-radius: var(--radius-sm); + } + + &__footer { + padding: 12px 16px; + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/experiments-v2/ExperimentsListPage.tsx b/frontend/web/components/experiments-v2/ExperimentsListPage.tsx new file mode 100644 index 000000000000..a56e01040c6d --- /dev/null +++ b/frontend/web/components/experiments-v2/ExperimentsListPage.tsx @@ -0,0 +1,183 @@ +import React, { FC, useMemo, useState } from 'react' +import { useHistory } from 'react-router-dom' +import Button from 'components/base/forms/Button' +import Input from 'components/base/forms/Input' +import EmptyState from 'components/EmptyState' +import StatusBadge from './shared/StatusBadge' +import { ExperimentListItem, ExperimentStatus, MOCK_EXPERIMENTS } from './types' +import './ExperimentsListPage.scss' + +type FilterTab = 'all' | ExperimentStatus + +const TABS: { label: string; value: FilterTab }[] = [ + { label: 'All', value: 'all' }, + { label: 'Running', value: 'running' }, + { label: 'Draft', value: 'draft' }, + { label: 'Completed', value: 'completed' }, +] + +const ExperimentsListPage: FC = () => { + const history = useHistory() + const [activeTab, setActiveTab] = useState('all') + const [search, setSearch] = useState('') + + const filtered = useMemo(() => { + let items = MOCK_EXPERIMENTS + if (activeTab !== 'all') { + items = items.filter((e) => e.status === activeTab) + } + if (search) { + const lower = search.toLowerCase() + items = items.filter( + (e) => + e.name.toLowerCase().includes(lower) || + e.linkedFlag.toLowerCase().includes(lower), + ) + } + return items + }, [activeTab, search]) + + const counts = useMemo(() => { + const c = { completed: 0, draft: 0, paused: 0, running: 0 } + MOCK_EXPERIMENTS.forEach((e) => { + c[e.status]++ + }) + return c + }, []) + + const handleRowClick = (experiment: ExperimentListItem) => { + // Navigate to results page (mock: uses current route context) + history.push( + `${document.location.pathname.replace('/experiments', '')}/experiments/${ + experiment.id + }`, + ) + } + + return ( +
+
+

Experiments

+ +
+ +
+
+ {TABS.map((tab) => ( + + ))} +
+
+ ) => + setSearch(e.target.value) + } + placeholder='Search experiments...' + search + size='small' + /> +
+
+ + {filtered.length > 0 ? ( + <> + + + + + + + + + + + + + {filtered.map((exp) => ( + handleRowClick(exp)} + > + + + + + + + + ))} + +
Experiment NameLinked FlagStatusVariationsPrimary MetricLast Updated
{exp.name} + + {exp.linkedFlag} + + + + {exp.variations}{exp.primaryMetric}{exp.lastUpdated}
+
+ {MOCK_EXPERIMENTS.length} experiments · {counts.running}{' '} + running · {counts.draft} draft · {counts.completed}{' '} + completed +
+ + ) : ( + + history.push( + `${document.location.pathname.replace( + '/experiments', + '', + )}/experiments/create`, + ) + } + > + Create Experiment + + ) : undefined + } + /> + )} +
+ ) +} + +export default ExperimentsListPage diff --git a/frontend/web/components/experiments-v2/MetricsLibraryPage.scss b/frontend/web/components/experiments-v2/MetricsLibraryPage.scss new file mode 100644 index 000000000000..c387174a8710 --- /dev/null +++ b/frontend/web/components/experiments-v2/MetricsLibraryPage.scss @@ -0,0 +1,210 @@ +.metrics-library-page { + display: flex; + flex-direction: column; + gap: 16px; + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + } + + &__title { + margin: 0 0 4px 0; + } + + &__subtitle { + margin: 0; + max-width: 640px; + } + + &__source-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-subtle); + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + + svg, + .icon { + color: var(--color-icon-action); + flex-shrink: 0; + } + + code { + font-family: var(--font-family); + background: transparent; + padding: 0; + color: var(--color-text-default); + } + + strong { + color: var(--color-text-default); + } + } + + &__source-banner-link { + margin-left: auto; + color: var(--color-text-action); + font-weight: var(--font-weight-medium); + text-decoration: none; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } + } + + &__toolbar { + display: flex; + align-items: center; + gap: 12px; + } + + &__search { + flex: 1; + + .input-container { + width: 100%; + } + } + + &__table { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + overflow: hidden; + } + + &__head { + display: flex; + background: var(--color-surface-emphasis); + } + + &__th { + padding: 12px 16px; + font-size: var(--font-caption-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + + &--name { + flex: 2; + } + + &--desc { + flex: 3; + } + + &--usage { + flex: 1; + } + + &--updated { + flex: 1; + } + + &--actions { + flex: 0 0 96px; + } + } + + &__row { + display: flex; + border-top: 1px solid var(--color-border-default); + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-hover); + } + } + + &__td { + padding: 14px 16px; + font-size: var(--font-body-size); + color: var(--color-text-default); + display: flex; + align-items: center; + + &--name { + flex: 2; + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + &--desc { + flex: 3; + color: var(--color-text-secondary); + } + + &--usage { + flex: 1; + color: var(--color-text-secondary); + } + + &--updated { + flex: 1; + color: var(--color-text-secondary); + font-size: var(--font-caption-size); + } + + &--actions { + flex: 0 0 96px; + gap: 4px; + } + } + + &__metric-name { + font-weight: var(--font-weight-semibold); + } + + &__source { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + + svg, + .icon { + color: var(--color-icon-secondary); + flex-shrink: 0; + } + + &--missing { + color: var(--color-text-warning, #b36b00); + } + } + + &__source-code { + font-family: var(--font-family); + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + background: transparent; + padding: 0; + } + + &__action-btn { + background: transparent; + border: none; + padding: 6px; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--color-icon-default); + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-hover); + color: var(--color-icon-emphasis); + } + + &--danger:hover { + color: var(--color-icon-danger); + } + } +} diff --git a/frontend/web/components/experiments-v2/MetricsLibraryPage.tsx b/frontend/web/components/experiments-v2/MetricsLibraryPage.tsx new file mode 100644 index 000000000000..3ed6a03447d1 --- /dev/null +++ b/frontend/web/components/experiments-v2/MetricsLibraryPage.tsx @@ -0,0 +1,248 @@ +import React, { FC, useMemo, useState } from 'react' +import AccountStore from 'common/stores/account-store' +import Button from 'components/base/forms/Button' +import EmptyState from 'components/EmptyState' +import Icon from 'components/icons/Icon' +import Input from 'components/base/forms/Input' +import CreateMetricForm from './shared/CreateMetricForm' +import { Metric, MOCK_METRICS } from './types' +import './MetricsLibraryPage.scss' + +type Mode = + | { kind: 'list' } + | { kind: 'create' } + | { kind: 'edit'; metric: Metric } + +const MetricsLibraryPage: FC = () => { + const [metrics, setMetrics] = useState(MOCK_METRICS) + const [search, setSearch] = useState('') + const [mode, setMode] = useState({ kind: 'list' }) + const organisationId = AccountStore.getOrganisation()?.id + const warehouseUrl = organisationId + ? `/organisation/${organisationId}/warehouse` + : undefined + + const filtered = useMemo(() => { + if (!search) return metrics + const lower = search.toLowerCase() + return metrics.filter( + (m) => + m.name.toLowerCase().includes(lower) || + m.description.toLowerCase().includes(lower), + ) + }, [metrics, search]) + + const handleCreateOrEditSubmit = (metric: Metric) => { + setMetrics((prev) => { + const existing = prev.findIndex((m) => m.id === metric.id) + if (existing >= 0) { + const next = [...prev] + next[existing] = metric + return next + } + return [metric, ...prev] + }) + setMode({ kind: 'list' }) + } + + const handleDelete = (metric: Metric) => { + openConfirm({ + body: ( + + Delete {metric.name}?{' '} + {metric.usageCount > 0 ? ( + <> + This metric is used by {metric.usageCount}{' '} + experiment + {metric.usageCount === 1 ? '' : 's'} — deleting it will remove it + from those experiments. + + ) : ( + 'This metric is not used by any experiments.' + )} + + ), + destructive: true, + onYes: () => setMetrics((prev) => prev.filter((m) => m.id !== metric.id)), + title: 'Delete metric?', + yesText: 'Delete', + }) + } + + if (mode.kind === 'create' || mode.kind === 'edit') { + return ( +
+ setMode({ kind: 'list' })} + onSubmit={handleCreateOrEditSubmit} + /> +
+ ) + } + + let listContent: React.ReactNode + if (metrics.length === 0) { + listContent = ( + setMode({ kind: 'create' })} + > + Create Metric + + } + /> + ) + } else if (filtered.length === 0) { + listContent = ( + + ) + } else { + listContent = ( +
+
+ + Name + + + Description + + + Used in + + + Last updated + + +
+ + {filtered.map((metric) => ( +
+ + + {metric.name} + + {metric.source ? ( + + + + {metric.source.table} + {metric.source.eventName + ? ` · ${metric.source.eventName}` + : ''} + {metric.source.valueColumn + ? ` · ${metric.source.valueColumn}` + : ''} + {metric.source.filter ? ` · ${metric.source.filter}` : ''} + + + ) : ( + + Source not configured + + )} + + + {metric.description} + + + {metric.usageCount === 0 + ? 'Not in use' + : `${metric.usageCount} experiment${ + metric.usageCount === 1 ? '' : 's' + }`} + + + {metric.lastUpdated} + + + + + +
+ ))} +
+ ) + } + + return ( +
+
+
+

Metrics

+

+ Metrics track the outcomes you measure across experiments. Primary + and secondary metrics drive experiment verdicts; guardrails flag + regressions. +

+
+
+ +
+ + + Metrics are computed from your Snowflake warehouse ( + FLAGSMITH_PROD.PUBLIC).{' '} + + {warehouseUrl && ( + + Manage connection → + + )} +
+ +
+
+ ) => + setSearch(e.target.value) + } + placeholder='Search metrics...' + search + /> +
+ +
+ + {listContent} +
+ ) +} + +MetricsLibraryPage.displayName = 'MetricsLibraryPage' +export default MetricsLibraryPage diff --git a/frontend/web/components/experiments-v2/NOTES.md b/frontend/web/components/experiments-v2/NOTES.md new file mode 100644 index 000000000000..ae52bf16bd16 --- /dev/null +++ b/frontend/web/components/experiments-v2/NOTES.md @@ -0,0 +1,182 @@ +# Experiments v2 — Prototype Notes + +Prototype-only decisions and deferred items that aren't worth putting in +production code but should survive a context handover. + +## Demo shortcuts + +Query-param affordances baked into the prototype for presenting and +screenshotting. None of them are visible in normal usage — no query param, +no demo UI. + +### Data Warehouse state shortcuts + +Defined in `web/components/warehouse/WarehousePage.tsx`. + +- **`?state=`** — jumps directly to a specific connection state + without clicking through the full flow. Values: + - `empty` (default) + - `configuring` — the form with Snowflake / BigQuery / Databricks picker + and the two-button Test / Connect footer + - `testing` — the 4-step "Connecting…" animation + - `connected` — the live-stats + connection-details page + - `error` — the auth-failed state with retry / edit actions +- **`?demo=1`** — shows an inline state-switcher pill bar above the + content. Click any pill to switch state live. The bar has a + warning-tinted dashed border so it reads unmistakably as demo-only. +- Combine them: `?demo=1&state=connected` opens on the connected state + with the switcher ready for live clicks. + +### Experiments empty-metrics shortcut (proposed, not built) + +See *Deferred: demo URL param for empty metrics* below. + +## Deferred: demo URL param for empty metrics + +**Context:** the Select Metrics step (`steps/SelectMetricsStep.tsx`) has an +empty state for when the metric catalog is empty — useful for showcasing the +"create your first metric" flow. It's not reachable by default because +`MOCK_METRICS` always has 5 entries, and the wizard's `INITIAL_STATE` +pre-selects three of them. + +**Decision:** gate empty-state demo behind a URL param rather than flipping +the default mock. Keeps the happy-path demo intact. + +**Proposed param:** `?demoEmptyMetrics=1` on the create-experiment route. +When present: + +- Pass `availableMetrics={[]}` to `SelectMetricsStep` (overrides the + `MOCK_METRICS` default). +- Skip the three pre-selected metrics in `CreateExperimentPage`'s + `INITIAL_STATE` so `state.metrics` starts empty too. + +**Parse via:** `Utils.fromParam()` in `CreateExperimentPage` (same pattern +used elsewhere in the codebase — e.g. `web/main.js`, `IntegrationList.tsx`). + +**Why not built yet:** low priority for the current prototype milestone; +designers can still see the empty state via the Storybook story or by +temporarily clearing `MOCK_METRICS`. + +## Design-system note: 1220px rail vs. full-width surfaces + +**Context:** every existing Flagsmith page wraps its content in +`
`, which caps at `max-width: +1220px` (see `web/styles/project/_layout.scss:10`). That cap is a legacy +choice sized for ~1280px monitors; on a modern 2560-wide display the +content floats in the centre with ~670px of dead space on each side. + +The new Warehouse page is deliberately **not** wrapped in `app-container +container` — it spans the viewport (with sensible padding on +`.warehouse-page`). The stats row, connection-details card, and form all +breathe naturally. + +**This is intentional**, not a consistency bug: + +- The 1220px rail is design debt from the existing app, not a target. +- Modern SaaS (Linear, Vercel, Stripe, Statsig) lets the outer container + breathe and constrains inner blocks per-content-type — forms/prose cap + at 720–800px for readability, tables and dashboards span wider. +- The warehouse page is showing what "right" looks like; the rest of the + app needs to follow, not the reverse. + +**Talking point for the demo:** "Notice how this new surface uses the +full viewport? That's intentional — it's part of the wider design-system +refresh we want to do. The 1220px cap on every other page is on our +audit list (#6606)." + +**Follow-up if accepted as a direction:** + +- Audit which pages benefit from full-width (usage, analytics, tables, + dashboards) vs. narrow (settings forms, prose). +- Replace the blanket `app-container` cap with per-page width policies, or + introduce a `.app-container--wide` / `.app-container--narrow` modifier. +- Track as part of the design-system audit (#6606), not a one-off warehouse + change. + +## Deferred: "Connected" indicator on the Integrations list card + +**Context:** the Data Warehouse entry in `integration_data` uses `external: +true` with a same-origin link (`/organisation/:organisationId/warehouse`), +so it renders via `IntegrationList.tsx` lines 144–155 — a plain `` CTA +with no active-integration row beneath. The card looks identical whether or +not a warehouse is connected. + +Drawer-based integrations (Datadog, Segment, Slack) show a connected row +via `IntegrationList.tsx` lines 316–354, but that path requires a real +backend endpoint (`GET /organisations/:id/integrations/:key/`) which the +warehouse prototype doesn't have. Other `external: true` integrations +(Jira) have the same gap — it's not specific to us. + +**Implication for the demo:** returning users can't tell from the +Integrations list whether they've already set up a warehouse. They have to +click through to the Warehouse page to see connection status. Acceptable +for the prototype; the presentation flow clicks through anyway. + +**Two follow-up options if this ships:** + +1. **Mock-only:** add `connected?: boolean` to the integration JSON and + render a green "Connected" pill in the card header. Cheapest, no backend. +2. **Real:** add `GET /organisations/:id/integrations/data-warehouse/` + returning the stored config, wire `IntegrationList` to fetch it like the + drawer-based ones. Matches existing infra and gives us a proper active + row (with Edit / Delete controls inline on the card). + +## Deferred: sample-size / duration calculator + +**Context:** the Audience step (`steps/AudienceStep.tsx`) currently shows +a soft one-liner under the variation split: *"Sampling N% (~M users) into +the experiment, split as Control ~X, Treatment ~Y. Actual +time-to-significance depends on traffic, baseline rate, and the lift +you're trying to detect."* + +The earlier prototype showed a fabricated *"Est. 14 days to significance"* +— removed because it had no statistical basis and would set false +expectations. + +**What a real implementation needs:** + +1. **Segment traffic estimate** — not just "how many identities match this + segment" but "how many of them hit this flag per day". Options: + - Cached daily segment counts (nightly cron, stale ≤24h) — cheapest MVP. + - Historical impressions from a new table tracking flag-evaluation + events per segment — what LaunchDarkly does. More infra but better + signal. +2. **Metric baseline rate (`p`)** — e.g. "conversion is 7% today". Either + the user types it, or we derive from historical metric data (needs a + metrics warehouse we don't have yet). +3. **Minimum Detectable Effect (MDE) input** — user specifies the smallest + lift they care about. Dedicated form field in the wizard. +4. **Power calculation** — standard formula for proportion metrics: + `n_per_arm ≈ 16 × p(1-p) / MDE²`; duration = + `n_per_arm × arms / daily_traffic`. + +**Why not built yet:** real feature, not a polish item. Needs backend +infra for (1) + (2) and a new wizard input for (3). Worth a separate +sprint once the rest of Experiments v2 ships. + +## Deferred: guardrail semantic-flip on the results page + +**Context:** metric roles (primary / secondary / guardrail) are now rendered +on the Experiment Results page via `MetricsComparisonTable`. Primary and +secondary metrics use the existing lift/significance colouring — positive +lift renders green, negative renders red. + +Guardrails should flip this: a **significant regression** on a guardrail +(positive lift on a lower-better metric, negative lift on a higher-better +metric) is a **failure** and should render as danger/warning, not neutral. +Example: page-load-time goes up 8% with p<0.01 — today that'd render as +"positive" because the lift bar only looks at `liftValue`'s sign; it should +render as a red "regression" warning. + +**What's needed:** +1. Propagate `MetricDirection` ('higher-better' | 'lower-better' | 'neither') + from `Metric` onto `MetricComparison` (it stops at the wizard today). +2. Compute "is this a regression?" per metric: + `role === 'guardrail' && isSignificant && sign(liftValue) !== wantedDirection`. +3. When true, override the lift-bar colour and significance label to a + danger tone, and consider adding a warning icon in the significance + column. + +**Why not built yet:** demo showed guardrails as a distinct visual category +which is the main UX win; the semantic-flip is a correctness refinement that +needs extra type work and would stretch tomorrow's scope. diff --git a/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss new file mode 100644 index 000000000000..08ad7ee556f9 --- /dev/null +++ b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss @@ -0,0 +1,141 @@ +.linked-experiment-section { + display: flex; + flex-direction: column; + gap: 16px; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + + .btn svg { + margin-right: 4px; + } + } + + &__title { + font-size: var(--font-h6-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + // Experiment card (inlined from LinkedExperimentCard) + &__card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-action); + border-radius: var(--radius-lg); + } + + &__card-top { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__card-name { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__details { + display: flex; + flex-direction: column; + gap: 10px; + } + + &__detail-row { + display: flex; + align-items: center; + gap: 8px; + + svg, + .icon { + color: var(--color-icon-secondary); + flex-shrink: 0; + } + } + + &__detail-label { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + } + + &__detail-value { + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + } + + &__progress { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__progress-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__progress-label { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__progress-value { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + } + + &__progress-track { + height: 6px; + background: var(--color-surface-muted); + border-radius: var(--radius-full); + overflow: hidden; + } + + &__progress-fill { + height: 100%; + background: var(--color-surface-action); + border-radius: var(--radius-full); + transition: width var(--duration-slow) var(--easing-standard); + } + + // Empty state + &__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + text-align: center; + + svg, + .icon { + color: var(--color-icon-secondary); + } + } + + &__empty-title { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__empty-desc { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + line-height: 1.5; + max-width: 400px; + } +} diff --git a/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx new file mode 100644 index 000000000000..5b2babe18174 --- /dev/null +++ b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx @@ -0,0 +1,119 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import StatusBadge from 'components/experiments-v2/shared/StatusBadge' +import { LinkedExperiment } from 'components/experiments-v2/types' +import './LinkedExperimentSection.scss' + +type LinkedExperimentSectionProps = { + experiment?: LinkedExperiment | null + onCreateExperiment?: () => void + onViewResults?: () => void +} + +const LinkedExperimentSection: FC = ({ + experiment, + onCreateExperiment, + onViewResults, +}) => { + const progressPercent = experiment + ? Math.round((experiment.sampleProgress / experiment.sampleTarget) * 100) + : 0 + + return ( +
+
+ + Linked Experiment + + {experiment && ( + + )} +
+ + {experiment ? ( +
+
+ + {experiment.name} + + +
+ +
+
+ + + Primary Metric: + + + {experiment.primaryMetric} + +
+
+ + + Running Since: + + + {experiment.runningSince} + +
+
+ + + Traffic: + + + {experiment.trafficSplit} + +
+
+ +
+
+ + Sample Progress + + + {progressPercent}% ({experiment.sampleProgress.toLocaleString()}{' '} + / {experiment.sampleTarget.toLocaleString()}) + +
+
+
+
+
+
+ ) : ( +
+ + + No experiment linked + + + Create an experiment to test variations of this feature flag and + measure their impact on your key metrics. + + +
+ )} +
+ ) +} + +LinkedExperimentSection.displayName = 'LinkedExperimentSection' +export default LinkedExperimentSection diff --git a/frontend/web/components/experiments-v2/shared/CreateMetricForm.scss b/frontend/web/components/experiments-v2/shared/CreateMetricForm.scss new file mode 100644 index 000000000000..1c2980e8f2fa --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/CreateMetricForm.scss @@ -0,0 +1,193 @@ +.create-metric-form { + display: flex; + flex-direction: column; + gap: 16px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + background: var(--color-surface-default); + padding: 24px; + box-shadow: var(--shadow-sm); + + &__header { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border-default); + } + + &__title { + font-size: var(--font-heading-sm-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__subtitle { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + } + + &__field { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__label { + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + margin: 0; + } + + &__measurement-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + } + + &__measurement-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + text-align: left; + cursor: pointer; + transition: all var(--duration-fast) var(--easing-standard); + + &:hover { + border-color: var(--color-border-action); + } + + &--selected { + border-color: var(--color-border-action); + background: var(--color-surface-action-subtle); + box-shadow: 0 0 0 1px var(--color-border-action); + } + } + + &__measurement-title { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__measurement-desc { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__measurement-ex { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + font-style: italic; + margin-top: 4px; + } + + &__direction-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__direction-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-full); + cursor: pointer; + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + background: var(--color-surface-default); + transition: all var(--duration-fast) var(--easing-standard); + + input { + display: none; + } + + &:hover { + border-color: var(--color-border-action); + } + + &--selected { + border-color: var(--color-border-action); + background: var(--color-surface-action-subtle); + color: var(--color-text-action); + font-weight: var(--font-weight-semibold); + } + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + } + + &__hint { + color: var(--color-text-secondary); + } + + &__sublabel { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; + } + + &__source { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-subtle); + } + + &__source-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + &__source-col { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__source-pill { + padding: 8px 12px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + font-weight: var(--font-weight-medium); + } + + &__source-pill-path { + font-family: var(--font-family); + color: var(--color-text-secondary); + font-weight: var(--font-weight-regular); + } + + &__source-select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + font-family: var(--font-family); + } +} diff --git a/frontend/web/components/experiments-v2/shared/CreateMetricForm.tsx b/frontend/web/components/experiments-v2/shared/CreateMetricForm.tsx new file mode 100644 index 000000000000..58fa27d42400 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/CreateMetricForm.tsx @@ -0,0 +1,355 @@ +import React, { FC, useState } from 'react' +import Button from 'components/base/forms/Button' +import Input from 'components/base/forms/Input' +import { + MeasurementType, + Metric, + MetricDirection, + MetricSource, +} from 'components/experiments-v2/types' +import './CreateMetricForm.scss' + +const WAREHOUSE_TABLES = [ + 'EVENTS', + 'PAGE_VIEWS', + 'SESSIONS', + 'TRANSACTIONS', + 'USERS', +] + +type CreateMetricFormProps = { + initialValues?: Metric + onCancel: () => void + onSubmit: (metric: Metric) => void +} + +const MEASUREMENT_OPTIONS: { + value: MeasurementType + title: string + description: string + example: string +}[] = [ + { + description: 'Number of times an event occurred', + example: 'ex: Number of purchases made', + title: 'Count', + value: 'count', + }, + { + description: 'Whether an event is seen at least once', + example: 'ex: Signup completion', + title: 'Occurrence', + value: 'occurrence', + }, + { + description: 'Magnitude of an event with an associated number', + example: 'ex: Latency or purchase value', + title: 'Value / Size', + value: 'value', + }, +] + +const DIRECTION_OPTIONS: { value: MetricDirection; label: string }[] = [ + { label: 'Higher is better', value: 'higher-better' }, + { label: 'Lower is better', value: 'lower-better' }, + { label: 'Neither — informational only', value: 'neither' }, +] + +const CreateMetricForm: FC = ({ + initialValues, + onCancel, + onSubmit, +}) => { + const isEdit = !!initialValues + const [name, setName] = useState(initialValues?.name ?? '') + const [description, setDescription] = useState( + initialValues?.description ?? '', + ) + const [measurementType, setMeasurementType] = useState( + initialValues?.measurementType ?? 'occurrence', + ) + const [direction, setDirection] = useState( + initialValues?.direction ?? 'higher-better', + ) + const [sourceTable, setSourceTable] = useState( + initialValues?.source?.table ?? 'EVENTS', + ) + const [sourceEventName, setSourceEventName] = useState( + initialValues?.source?.eventName ?? '', + ) + const [sourceValueColumn, setSourceValueColumn] = useState( + initialValues?.source?.valueColumn ?? '', + ) + const [sourceFilter, setSourceFilter] = useState( + initialValues?.source?.filter ?? '', + ) + + const canSubmit = name.trim().length > 0 && description.trim().length > 0 + + const buildSource = (): MetricSource | undefined => { + const table = sourceTable.trim() + if (!table) return undefined + const source: MetricSource = { table, warehouse: 'snowflake' } + const needsEventName = + measurementType === 'count' || measurementType === 'occurrence' + if (needsEventName && sourceEventName.trim()) { + source.eventName = sourceEventName.trim() + } + if (measurementType === 'value' && sourceValueColumn.trim()) { + source.valueColumn = sourceValueColumn.trim() + } + if (sourceFilter.trim()) { + source.filter = sourceFilter.trim() + } + return source + } + + const handleSubmit = () => { + if (!canSubmit) return + const source = buildSource() + if (initialValues) { + onSubmit({ + ...initialValues, + description: description.trim(), + direction, + lastUpdated: 'Just now', + measurementType, + name: name.trim(), + source, + }) + } else { + onSubmit({ + description: description.trim(), + direction, + id: `metric-${Date.now()}`, + lastUpdated: 'Just now', + measurementType, + name: name.trim(), + role: 'secondary', + source, + usageCount: 0, + }) + } + } + + return ( +
+
+ + {isEdit ? 'Edit Metric' : 'Create Metric'} + + + Metrics capture the outcomes your experiments measure. + +
+ +
+ + ) => + setName(e.target.value) + } + placeholder='e.g. Signup Completion Rate' + /> +
+ +
+ + ) => + setDescription(e.target.value) + } + placeholder='What does this metric measure?' + /> +
+ +
+ +
+ {MEASUREMENT_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ +
+ {DIRECTION_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ + + Where this metric is collected from. Reads from your connected + warehouse. + +
+
+
+ +
+ Snowflake ·{' '} + + FLAGSMITH_PROD.PUBLIC + +
+
+
+ + +
+
+ + {(measurementType === 'count' || + measurementType === 'occurrence') && ( +
+ + ) => + setSourceEventName(e.target.value) + } + placeholder='e.g. checkout_completed' + /> +
+ )} + + {measurementType === 'value' && ( +
+ + ) => + setSourceValueColumn(e.target.value) + } + placeholder='e.g. amount_usd' + /> +
+ )} + +
+ + ) => + setSourceFilter(e.target.value) + } + placeholder="e.g. status = 'complete'" + /> +
+
+
+ +
+ + +
+
+ ) +} + +CreateMetricForm.displayName = 'CreateMetricForm' +export default CreateMetricForm diff --git a/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss new file mode 100644 index 000000000000..2258cfb90661 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss @@ -0,0 +1,40 @@ +.experiment-stat-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + flex: 1; + + &__label { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__value { + font-family: var(--font-family); + font-size: var(--font-h3-size); + font-weight: var(--font-weight-bold); + color: var(--color-text-default); + + &--positive { + color: var(--color-text-success); + } + + &--negative { + color: var(--color-text-danger); + } + + &--neutral { + color: var(--color-text-secondary); + } + } + + &__subtitle { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx new file mode 100644 index 000000000000..5eef91c2a119 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react' +import { LiftDirection } from 'components/experiments-v2/types' +import './ExperimentStatCard.scss' + +type ExperimentStatCardProps = { + label: string + value: string | number + subtitle?: string + trend?: LiftDirection +} + +const ExperimentStatCard: FC = ({ + label, + subtitle, + trend, + value, +}) => { + return ( +
+ {label} + + {typeof value === 'number' ? value.toLocaleString() : value} + + {subtitle && ( + {subtitle} + )} +
+ ) +} + +ExperimentStatCard.displayName = 'ExperimentStatCard' +export default ExperimentStatCard diff --git a/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss new file mode 100644 index 000000000000..f7b961b6f4d4 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss @@ -0,0 +1,178 @@ +.metrics-comparison-table { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + overflow: hidden; + + &__head { + display: flex; + background: var(--color-surface-emphasis); + } + + &__th { + flex: 1; + padding: 12px 16px; + font-size: var(--font-caption-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + + &--lift { + flex: 2; + } + } + + &__row { + display: flex; + border-top: 1px solid var(--color-border-default); + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-hover); + } + + &--primary { + background: var(--color-surface-action-subtle); + + &:hover { + background: var(--color-surface-action-subtle); + } + + .metrics-comparison-table__metric-name { + font-weight: var(--font-weight-semibold); + } + } + } + + &__td { + flex: 1; + padding: 14px 16px; + font-size: var(--font-body-size); + color: var(--color-text-default); + + &--mono { + font-family: var(--font-family); + } + + &--name { + display: flex; + align-items: center; + gap: 8px; + } + + &--lift { + flex: 2; + display: flex; + align-items: center; + gap: 12px; + } + + &--significance { + display: flex; + align-items: center; + gap: 4px; + } + + &--significant { + color: var(--color-text-success); + } + } + + &__metric-name { + flex: 1; + } + + &__role { + font-size: var(--font-caption-xs-size); + font-weight: var(--font-weight-semibold); + padding: 2px 8px; + border-radius: var(--radius-full); + flex-shrink: 0; + + &--primary { + background: var(--color-surface-action); + color: var(--color-surface-default); + } + + &--secondary { + border: 1px solid var(--color-border-default); + color: var(--color-text-secondary); + } + + &--guardrail { + background: var(--color-surface-warning-subtle, #fff7e6); + color: var(--color-text-warning, #b36b00); + border: 1px solid var(--color-border-warning, #f2c078); + } + } + + &__lift-bar { + position: relative; + flex: 1; + height: 8px; + min-width: 80px; + background: var(--color-surface-emphasis); + border-radius: var(--radius-sm); + overflow: hidden; + } + + &__lift-bar-axis { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: var(--color-border-default); + transform: translateX(-0.5px); + } + + &__lift-bar-fill { + position: absolute; + top: 0; + bottom: 0; + width: var(--lift-bar-width, 0%); + border-radius: var(--radius-sm); + transition: width var(--duration-base) var(--easing-standard); + + .metrics-comparison-table__lift-bar--side-right & { + left: 50%; + } + + .metrics-comparison-table__lift-bar--side-left & { + right: 50%; + } + + .metrics-comparison-table__lift-bar--tone-positive & { + background: var(--color-text-success); + } + + .metrics-comparison-table__lift-bar--tone-negative & { + background: var(--color-text-danger); + } + + .metrics-comparison-table__lift-bar--tone-neutral & { + background: var(--color-text-secondary); + opacity: 0.5; + } + } + + &__lift-label { + font-family: var(--font-family); + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + min-width: 56px; + text-align: right; + + &--positive { + color: var(--color-text-success); + } + + &--negative { + color: var(--color-text-danger); + } + + &--neutral { + color: var(--color-text-secondary); + font-weight: var(--font-weight-regular); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx new file mode 100644 index 000000000000..5f95f1f27d35 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx @@ -0,0 +1,123 @@ +import React, { CSSProperties, FC } from 'react' +import Icon from 'components/icons/Icon' +import { MetricComparison, MetricRole } from 'components/experiments-v2/types' +import './MetricsComparisonTable.scss' + +type MetricsComparisonTableProps = { + metrics: MetricComparison[] +} + +type LiftBarStyle = CSSProperties & { '--lift-bar-width': string } + +const ROLE_ORDER: Record = { + guardrail: 2, + primary: 0, + secondary: 1, +} + +const ROLE_LABEL: Record = { + guardrail: 'Guardrail', + primary: 'Primary', + secondary: 'Secondary', +} + +const MetricsComparisonTable: FC = ({ + metrics, +}) => { + const maxMagnitude = metrics.reduce( + (max, m) => Math.max(max, Math.abs(m.liftValue)), + 0, + ) + + const ordered = [...metrics].sort( + (a, b) => ROLE_ORDER[a.role] - ROLE_ORDER[b.role], + ) + + return ( +
+
+ Metric + Control + Treatment B + + Lift + + Significance +
+ + {ordered.map((metric) => { + const barWidthPercent = + maxMagnitude === 0 + ? 0 + : (Math.abs(metric.liftValue) / maxMagnitude) * 50 + const barStyle: LiftBarStyle = { + '--lift-bar-width': `${barWidthPercent}%`, + } + const side = metric.liftValue >= 0 ? 'right' : 'left' + let tone: 'positive' | 'negative' | 'neutral' = 'neutral' + if (metric.isSignificant) { + tone = metric.liftDirection === 'negative' ? 'negative' : 'positive' + } + + return ( +
+ + + {metric.name} + + + {ROLE_LABEL[metric.role]} + + + + {metric.control} + + + {metric.treatment} + + +
+ ) + })} +
+ ) +} + +MetricsComparisonTable.displayName = 'MetricsComparisonTable' +export default MetricsComparisonTable diff --git a/frontend/web/components/experiments-v2/shared/MetricsTrendChart.scss b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.scss new file mode 100644 index 000000000000..3930767afaa7 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.scss @@ -0,0 +1,24 @@ +.metrics-trend-chart { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + padding: 20px; + + &__controls { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + } + + &__label { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + margin: 0; + } + + &__select { + flex: 0 0 280px; + } +} diff --git a/frontend/web/components/experiments-v2/shared/MetricsTrendChart.tsx b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.tsx new file mode 100644 index 000000000000..5b95e77a9a1e --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.tsx @@ -0,0 +1,79 @@ +import React, { FC, useMemo, useState } from 'react' +import SearchableSelect, { + OptionType, +} from 'components/base/select/SearchableSelect' +import LineChart from 'components/charts/LineChart' +import { MetricTrend } from 'components/experiments-v2/types' +import './MetricsTrendChart.scss' + +type MetricsTrendChartProps = { + trends: MetricTrend[] +} + +const SERIES = ['control', 'treatment'] +const SERIES_LABELS: Record = { + control: 'Control', + treatment: 'Treatment B', +} +const COLOR_MAP: Record = { + control: 'var(--green-500)', + treatment: 'var(--purple-500)', +} + +const formatValue = (unit: MetricTrend['unit'], value: number): string => { + if (unit === '$') return `$${value.toFixed(2)}` + if (unit === '%') return `${value.toFixed(1)}%` + return `${value.toFixed(2)}s` +} + +const formatAxis = (unit: MetricTrend['unit'], value: number): string => { + if (unit === '$') return `$${value.toFixed(0)}` + return `${value.toFixed(1)}${unit}` +} + +const MetricsTrendChart: FC = ({ trends }) => { + const [selectedMetric, setSelectedMetric] = useState( + trends[0]?.metricName ?? '', + ) + + const options = useMemo( + () => trends.map((t) => ({ label: t.metricName, value: t.metricName })), + [trends], + ) + + const selected = trends.find((t) => t.metricName === selectedMetric) + + if (!selected) { + return null + } + + return ( +
+
+ +
+ setSelectedMetric(String(opt.value))} + options={options} + placeholder='Select a metric...' + /> +
+
+ + formatAxis(selected.unit, value)} + tooltipValueFormatter={(value) => formatValue(selected.unit, value)} + tooltipLabelFormatter={(label) => `Day ${label}`} + /> +
+ ) +} + +MetricsTrendChart.displayName = 'MetricsTrendChart' +export default MetricsTrendChart diff --git a/frontend/web/components/experiments-v2/shared/SelectableCard.scss b/frontend/web/components/experiments-v2/shared/SelectableCard.scss new file mode 100644 index 000000000000..4167d78e709d --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/SelectableCard.scss @@ -0,0 +1,143 @@ +.selectable-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-radius: var(--radius-xl); + border: 2px solid transparent; + box-shadow: inset 0 0 0 1px var(--color-border-default); + background: transparent; + cursor: pointer; + text-align: left; + width: 100%; + transition: + border-color var(--duration-fast) var(--easing-standard), + box-shadow var(--duration-fast) var(--easing-standard), + background var(--duration-fast) var(--easing-standard); + + &:hover { + box-shadow: inset 0 0 0 1px var(--color-border-strong); + } + + &--selected { + border-color: var(--color-border-action); + box-shadow: none; + background: var(--color-surface-action-subtle); + + svg, + .icon { + color: var(--color-icon-action); + } + } + + &__content { + display: flex; + flex-direction: column; + gap: 4px; + + svg, + .icon { + color: var(--color-icon-secondary); + margin-bottom: 4px; + } + } + + &__title { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__description { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 4px; + } + + &__tag { + font-size: var(--font-caption-xs-size); + font-weight: var(--font-weight-medium); + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--color-surface-emphasis); + color: var(--color-text-secondary); + font-family: var(--font-family); + } + + &__aside { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + &__role-group { + display: inline-flex; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-surface-default); + } + + &__role-pill { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-medium); + padding: 4px 10px; + border: 0; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: + background var(--duration-fast) var(--easing-standard), + color var(--duration-fast) var(--easing-standard); + + + .selectable-card__role-pill { + border-left: 1px solid var(--color-border-default); + } + + &:hover { + background: var(--color-surface-action-subtle); + color: var(--color-text-default); + } + + &--active { + background: var(--color-surface-action); + color: var(--color-surface-default); + + &:hover { + background: var(--color-surface-action); + color: var(--color-surface-default); + } + } + } + + &__badge { + font-size: var(--font-caption-xs-size); + font-weight: var(--font-weight-semibold); + padding: 4px 12px; + border-radius: var(--radius-full); + flex-shrink: 0; + + &--primary { + background: var(--color-surface-action); + color: var(--color-surface-default); + } + + &--secondary { + border: 1px solid var(--color-border-default); + color: var(--color-text-secondary); + } + + &--guardrail { + background: var(--color-surface-warning-subtle, #fff7e6); + color: var(--color-text-warning, #b36b00); + border: 1px solid var(--color-border-warning, #f2c078); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/SelectableCard.tsx b/frontend/web/components/experiments-v2/shared/SelectableCard.tsx new file mode 100644 index 000000000000..10a0799ac7eb --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/SelectableCard.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import Icon, { IconName } from 'components/icons/Icon' +import './SelectableCard.scss' + +type BadgeVariant = 'primary' | 'secondary' | 'guardrail' + +type RoleOption = { label: string; value: V } + +type SelectableCardProps = { + selected: boolean + onClick: () => void + icon?: IconName + title: string + description: string + badge?: { label: string; variant: BadgeVariant } + /** Small info tags rendered under the title (e.g. measurement type). */ + tags?: string[] + /** Segmented role picker rendered on the right when selected. Mutually + * exclusive with `badge`. */ + roleSelector?: { + value: V + options: RoleOption[] + onChange: (value: V) => void + } +} + +const SelectableCard = ({ + badge, + description, + icon, + onClick, + roleSelector, + selected, + tags, + title, +}: SelectableCardProps) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + } + return ( +
+
+ {icon && } + {title} + {description} + {tags && tags.length > 0 && ( +
+ {tags.map((t) => ( + + {t} + + ))} +
+ )} +
+
+ {roleSelector ? ( +
+ {roleSelector.options.map((opt) => ( + + ))} +
+ ) : ( + badge && ( + + {badge.label} + + ) + )} +
+
+ ) +} + +SelectableCard.displayName = 'SelectableCard' +export default SelectableCard diff --git a/frontend/web/components/experiments-v2/shared/StatusBadge.scss b/frontend/web/components/experiments-v2/shared/StatusBadge.scss new file mode 100644 index 000000000000..75e181002a67 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/StatusBadge.scss @@ -0,0 +1,42 @@ +.status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: var(--font-caption-xs-size); + font-weight: var(--font-weight-semibold); + + &__dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + } + + &--running { + background: var(--color-surface-success); + color: var(--color-text-success); + + .status-badge__dot { + background: var(--color-text-success); + } + } + + &--paused { + background: var(--color-surface-warning); + color: var(--color-text-warning); + + .status-badge__dot { + background: var(--color-text-warning); + } + } + + &--completed { + background: var(--color-surface-muted); + color: var(--color-text-secondary); + + .status-badge__dot { + background: var(--color-text-secondary); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/StatusBadge.tsx b/frontend/web/components/experiments-v2/shared/StatusBadge.tsx new file mode 100644 index 000000000000..d3f9777e7b97 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/StatusBadge.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react' +import { ExperimentStatus } from 'components/experiments-v2/types' +import './StatusBadge.scss' + +type StatusBadgeProps = { + status: ExperimentStatus +} + +const STATUS_LABELS: Record = { + completed: 'Completed', + paused: 'Paused', + running: 'Running', +} + +const StatusBadge: FC = ({ status }) => { + return ( + + + {STATUS_LABELS[status]} + + ) +} + +StatusBadge.displayName = 'StatusBadge' +export default StatusBadge diff --git a/frontend/web/components/experiments-v2/shared/VariationTable.scss b/frontend/web/components/experiments-v2/shared/VariationTable.scss new file mode 100644 index 000000000000..6504fddff3b2 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/VariationTable.scss @@ -0,0 +1,121 @@ +.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; + } + + &--actions { + width: 40px; + } + } + + &__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; + } + + &--desc { + flex: 1; + } + + &--value { + width: 100px; + } + + &--actions { + width: 40px; + display: flex; + justify-content: center; + } + } + + &__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); + } + + &__action-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--color-icon-secondary); + border-radius: var(--radius-sm); + transition: color var(--duration-fast) var(--easing-standard); + + &:hover { + color: var(--color-icon-danger); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/VariationTable.tsx b/frontend/web/components/experiments-v2/shared/VariationTable.tsx new file mode 100644 index 000000000000..87452c93ea7f --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/VariationTable.tsx @@ -0,0 +1,102 @@ +import React, { FC } from 'react' +import Icon from 'components/icons/Icon' +import { CONTROL_COLOUR, Variation } from 'components/experiments-v2/types' +import './VariationTable.scss' + +type VariationTableProps = { + variations: Variation[] + /** + * Renders an implicit "Control" row above the variations, reflecting that + * control is the flag's base value rather than a peer variation. + */ + controlValue?: string + editable?: boolean + onRemove?: (id: string) => void +} + +const VariationTable: FC = ({ + controlValue, + editable = false, + onRemove, + variations, +}) => { + return ( +
+
+ + Name + + + Description + + + Value + + {editable && ( + + )} +
+ + {controlValue !== undefined && ( +
+
+ + Control + control +
+
+ + Flag's base value — the baseline for comparison + +
+
+ {controlValue} +
+ {editable && ( +
+ )} +
+ )} + + {variations.map((variation) => ( +
+
+ + {variation.name} +
+
+ + {variation.description} + +
+
+ + {variation.value} + +
+ {editable && ( +
+ +
+ )} +
+ ))} +
+ ) +} + +VariationTable.displayName = 'VariationTable' +export default VariationTable diff --git a/frontend/web/components/experiments-v2/steps/AudienceStep.scss b/frontend/web/components/experiments-v2/steps/AudienceStep.scss new file mode 100644 index 000000000000..315760fb58b3 --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/AudienceStep.scss @@ -0,0 +1,226 @@ +.audience-step { + display: flex; + flex-direction: column; + gap: 20px; + + &__env { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 14px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + font-size: var(--font-body-sm-size); + } + + &__env-item { + display: flex; + align-items: center; + gap: 8px; + } + + &__env-divider { + width: 1px; + align-self: stretch; + background: var(--color-border-default); + } + + &__env-label { + color: var(--color-text-secondary); + } + + &__env-value { + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__section { + display: flex; + flex-direction: column; + gap: 10px; + padding: 20px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + background: var(--color-surface-default); + } + + &__section-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__section-title { + margin: 0; + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__optional-badge { + margin-left: 8px; + padding: 2px 6px; + font-size: var(--font-body-xs-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + background: var(--color-surface-subtle); + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__label { + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + } + + &__segment-link { + color: var(--color-text-action); + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + &__hint { + color: var(--color-text-secondary); + } + + &__targeting-toggle { + display: flex; + gap: 8px; + align-items: center; + } + + &__targeting-clear { + margin-left: auto; + } + + &__sample-presets { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + } + + &__sample-preset { + padding: 8px 14px; + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--duration-fast) var(--easing-standard); + + &:hover:not(&--active) { + background: var(--color-surface-subtle); + } + + &--active { + background: var(--color-surface-action-subtle); + border-color: var(--color-border-action); + color: var(--color-text-action); + } + } + + &__sample-custom { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + + input { + width: 80px; + padding: 8px 10px; + font-family: var(--font-family); + font-size: var(--font-body-size); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + text-align: right; + } + } + + &__arms { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; + } + + &__arm-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + } + + &__arm-dot { + width: 10px; + height: 10px; + border-radius: var(--radius-full); + flex-shrink: 0; + } + + &__arm-name { + flex: 1; + font-size: var(--font-body-size); + color: var(--color-text-default); + } + + &__arm-input { + display: flex; + align-items: center; + gap: 4px; + + input { + width: 64px; + padding: 6px 8px; + font-family: var(--font-family); + font-size: var(--font-body-size); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + text-align: right; + } + } + + &__bar { + display: flex; + height: 10px; + width: 100%; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--color-surface-emphasis); + margin-top: 4px; + } + + &__bar-segment { + height: 100%; + transition: width var(--duration-fast) var(--easing-standard); + } + + &__sample-estimate { + 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; + } + } +} diff --git a/frontend/web/components/experiments-v2/steps/AudienceStep.tsx b/frontend/web/components/experiments-v2/steps/AudienceStep.tsx new file mode 100644 index 000000000000..a66dd25173ba --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/AudienceStep.tsx @@ -0,0 +1,382 @@ +import React, { FC, useMemo } from 'react' +import { useParams } from 'react-router-dom' +import Banner from 'components/Banner/Banner' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import SearchableSelect from 'components/base/select/SearchableSelect' +import { OptionType } from 'components/base/select/SearchableSelect' +import { + ArmWeight, + AudienceConfig, + CONTROL_ARM_ID, + CONTROL_COLOUR, + FlagOption, + MOCK_ENVIRONMENT_USER_COUNT, + MOCK_SEGMENTS, + SAMPLE_SIZE_PRESETS, + Variation, +} from 'components/experiments-v2/types' +import './AudienceStep.scss' + +type Arm = { + id: string + name: string + value: string + colour: string +} + +type AudienceStepProps = { + audience: AudienceConfig + flag: FlagOption | null + controlValue: string + variations: Variation[] + environmentName: string + onChange: (audience: AudienceConfig) => void +} + +export const buildExperimentArms = ( + controlValue: string, + variations: Variation[], +): Arm[] => [ + { + colour: CONTROL_COLOUR, + id: CONTROL_ARM_ID, + name: 'Control', + value: controlValue, + }, + ...variations.map((v) => ({ + colour: v.colour, + id: v.id, + name: v.name, + value: v.value, + })), +] + +export const splitEvenly = (armIds: string[]): ArmWeight[] => { + if (armIds.length === 0) return [] + const base = Math.floor(100 / armIds.length) + const remainder = 100 - base * armIds.length + return armIds.map((id, i) => ({ + armId: id, + weight: base + (i < remainder ? 1 : 0), + })) +} + +const getWeight = (weights: ArmWeight[] | undefined, armId: string): number => + (weights ?? []).find((w) => w.armId === armId)?.weight ?? 0 + +const clampSamplePercentage = (value: number): number => + Math.max(1, Math.min(100, Math.round(value))) + +const AudienceStep: FC = ({ + audience, + controlValue, + environmentName, + flag, + onChange, + variations, +}) => { + const { projectId } = useParams<{ projectId?: string }>() + const segmentsUrl = projectId ? `/project/${projectId}/segments` : undefined + + const arms = useMemo( + () => buildExperimentArms(controlValue, variations), + [controlValue, variations], + ) + + const selectedSegment = MOCK_SEGMENTS.find( + (s) => s.value === audience.segmentId, + ) + + const handleSegmentChange = (segmentId: string | null) => { + onChange({ ...audience, segmentId }) + } + + const handleSamplePercentageChange = (next: number) => { + onChange({ ...audience, samplePercentage: clampSamplePercentage(next) }) + } + + const handleWeightChange = (armId: string, value: string) => { + const newWeight = Math.max(0, Math.min(100, Number(value) || 0)) + const others = arms.filter((a) => a.id !== armId) + if (others.length === 0) { + onChange({ ...audience, weights: [{ armId, weight: 100 }] }) + return + } + const targetOthersTotal = 100 - newWeight + const currentOthers = others.map((a) => ({ + armId: a.id, + weight: getWeight(audience.weights, a.id), + })) + const othersSum = currentOthers.reduce((s, o) => s + o.weight, 0) + + let rebalanced: ArmWeight[] + if (othersSum === 0) { + const base = Math.floor(targetOthersTotal / others.length) + const remainder = targetOthersTotal - base * others.length + rebalanced = currentOthers.map((o, i) => ({ + armId: o.armId, + weight: base + (i < remainder ? 1 : 0), + })) + } else { + const scaled = currentOthers.map((o) => { + const exact = (o.weight / othersSum) * targetOthersTotal + return { armId: o.armId, exact, floor: Math.floor(exact) } + }) + const floorSum = scaled.reduce((s, o) => s + o.floor, 0) + const toDistribute = targetOthersTotal - floorSum + const byFrac = [...scaled] + .map((s) => ({ ...s, frac: s.exact - s.floor })) + .sort((a, b) => b.frac - a.frac) + byFrac.forEach((s, i) => { + s.floor += i < toDistribute ? 1 : 0 + }) + const byId = new Map(byFrac.map((s) => [s.armId, s.floor])) + rebalanced = currentOthers.map((o) => ({ + armId: o.armId, + weight: byId.get(o.armId) ?? 0, + })) + } + + const nextWeights = arms.map((a) => { + if (a.id === armId) return { armId: a.id, weight: newWeight } + return ( + rebalanced.find((r) => r.armId === a.id) ?? { armId: a.id, weight: 0 } + ) + }) + onChange({ ...audience, weights: nextWeights }) + } + + const handleSplitEvenly = () => { + onChange({ + ...audience, + weights: splitEvenly(arms.map((a) => a.id)), + }) + } + + const eligibleUsers = + selectedSegment?.estimatedUsers ?? MOCK_ENVIRONMENT_USER_COUNT + const sampledUsers = Math.round( + (eligibleUsers * audience.samplePercentage) / 100, + ) + + const armEstimates = arms.map((a) => ({ + arm: a, + users: Math.round((sampledUsers * getWeight(audience.weights, a.id)) / 100), + })) + + const conflictingOverride = audience.segmentId + ? flag?.existingSegmentOverrides.find( + (o) => o.segmentId === audience.segmentId, + ) + : undefined + + const isCustomSamplePercentage = !SAMPLE_SIZE_PRESETS.includes( + audience.samplePercentage as (typeof SAMPLE_SIZE_PRESETS)[number], + ) + + return ( +
+
+
+ Environment + {environmentName} +
+ +
+ Bucketed by + identity +
+
+ +
+
+

+ Targeting + Optional +

+ {audience.segmentId && ( + + )} +
+ + Filter the experiment to a specific segment of users. Leave empty to + run on all identities in the environment. + + handleSegmentChange(opt.value)} + options={MOCK_SEGMENTS} + placeholder='All users in this environment' + /> + {segmentsUrl && ( + + Need a different filter (combination of traits, geography, plan)?{' '} +
+ Create a new segment on the Segments page + + . + + )} + {conflictingOverride && flag && ( + + + {conflictingOverride.segmentLabel} already has an + override on {flag.label}. Pick a different + segment, or remove the existing override on the Features page + first. + + + )} +
+ +
+
+

Sample size

+
+ + What percentage of eligible users enters the experiment? The rest keep + the flag's environment default and aren't part of the + result. + +
+ {SAMPLE_SIZE_PRESETS.map((preset) => { + const isActive = + !isCustomSamplePercentage && audience.samplePercentage === preset + return ( + + ) + })} + +
+ {isCustomSamplePercentage && ( +
+ + handleSamplePercentageChange(Number(e.target.value) || 1) + } + /> + % +
+ )} +
+ +
+
+

Variation split

+ +
+ + Distribute sampled users across control and treatment variations. + Control takes one of the slots; weights must sum to 100. + + +
+ {arms.map((arm) => ( +
+ + {arm.name} +
+ handleWeightChange(arm.id, e.target.value)} + /> + % +
+
+ ))} +
+ +
+ {arms.map((arm) => { + const w = getWeight(audience.weights, arm.id) + if (w === 0) return null + return ( +
+ ) + })} +
+
+ +
+ + + {selectedSegment ? ( + <> + {selectedSegment.label} has{' '} + {eligibleUsers.toLocaleString()} eligible users.{' '} + + ) : ( + <> + {eligibleUsers.toLocaleString()} eligible users in this + environment.{' '} + + )} + Sampling {audience.samplePercentage}% (~ + {sampledUsers.toLocaleString()}) into the experiment, split as{' '} + {armEstimates + .filter((e) => getWeight(audience.weights, e.arm.id) > 0) + .map((e, i, arr) => ( + + {i > 0 && (i === arr.length - 1 ? ' and ' : ', ')} + {e.arm.name} ~{e.users.toLocaleString()} + + ))} + . Actual time-to-significance depends on traffic, baseline rate, and + the lift you're trying to detect. + +
+
+ ) +} + +AudienceStep.displayName = 'AudienceStep' +export default AudienceStep diff --git a/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss new file mode 100644 index 000000000000..a2269befc1a6 --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss @@ -0,0 +1,63 @@ +.experiment-details-step { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + + &__field { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__label { + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + } + + &__required { + color: var(--color-text-danger); + } + + &__hint { + color: var(--color-text-secondary); + } + + &__textarea { + width: 100%; + min-height: 80px; + padding: 10px 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + background: transparent; + color: var(--color-text-default); + font-size: var(--font-body-size); + font-family: inherit; + resize: vertical; + transition: border-color var(--duration-fast) var(--easing-standard); + + &::placeholder { + color: var(--color-text-disabled); + } + + &:focus { + outline: none; + border-color: var(--color-border-action); + } + } + + &__type-cards { + display: flex; + gap: 12px; + } + + &__date-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + } +} diff --git a/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx new file mode 100644 index 000000000000..30e728642593 --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx @@ -0,0 +1,76 @@ +import React, { FC } from 'react' +import Input from 'components/base/forms/Input' +import { ExperimentDetails } from 'components/experiments-v2/types' +import './ExperimentDetailsStep.scss' + +type ExperimentDetailsStepProps = { + details: ExperimentDetails + onChange: (details: ExperimentDetails) => void +} + +const ExperimentDetailsStep: FC = ({ + details, + onChange, +}) => { + return ( +
+
+ + ) => + onChange({ ...details, name: e.target.value }) + } + placeholder='e.g. Checkout Flow Redesign' + /> +
+ +
+ +