@@ -11,6 +11,7 @@ import {
1111 PiArrowSquareOut ,
1212 PiCaretRightFill ,
1313 PiFlagFill ,
14+ PiFlaskFill ,
1415 PiInfo ,
1516 PiLinkBold ,
1617 PiDesktopFill ,
@@ -26,14 +27,18 @@ import * as Accordion from "@radix-ui/react-accordion";
2627import React , { CSSProperties , useEffect , useMemo , useState } from "react" ;
2728import {
2829 ExperimentWithFeatures ,
30+ formatExperimentKey ,
31+ getExperimentDisplayName ,
32+ getHoldoutFeatureId ,
33+ holdoutIdFromFid ,
2934 HEADER_H ,
3035 LEFT_PERCENT ,
3136} from "./ExperimentsTab" ;
3237import useGlobalState from "@/app/hooks/useGlobalState" ;
3338import { APP_ORIGIN , CLOUD_APP_ORIGIN } from "@/app/components/Settings" ;
3439import useTabState from "@/app/hooks/useTabState" ;
3540import { SelectedExperiment } from "@/app/components/ExperimentsTab" ;
36- import { AutoExperimentVariation , isURLTargeted } from "@growthbook/growthbook" ;
41+ import { AutoExperimentVariation , FeatureDefinition , isURLTargeted } from "@growthbook/growthbook" ;
3742import clsx from "clsx" ;
3843import DebugLogger , { DebugLogAccordion } from "@/app/components/DebugLogger" ;
3944import { TbEyeSearch } from "react-icons/tb" ;
@@ -91,6 +96,59 @@ export default function ExperimentDetail({
9196 undefined ,
9297 ) ;
9398
99+ const [ features ] = useTabState < Record < string , FeatureDefinition > > (
100+ "features" ,
101+ { } ,
102+ ) ;
103+
104+ const holdoutMembers = useMemo ( ( ) => {
105+ const holdoutFid = selectedExperiment ?. experiment
106+ ? getHoldoutFeatureId ( selectedExperiment . experiment )
107+ : undefined ;
108+ if ( ! holdoutFid ) return null ;
109+
110+ const heldFeatures : string [ ] = [ ] ;
111+ const heldExperiments : string [ ] = [ ] ;
112+
113+ for ( const [ fid , feature ] of Object . entries ( features ) ) {
114+ const rules = feature . rules ?? [ ] ;
115+ const rule0 = rules [ 0 ] ;
116+ if ( ! rule0 ) continue ;
117+ const isHoldoutGate = rule0 . parentConditions ?. some (
118+ ( pc : { id ?: string } ) => pc . id === holdoutFid ,
119+ ) ;
120+ if ( ! isHoldoutGate ) continue ;
121+
122+ heldFeatures . push ( fid ) ;
123+
124+ for ( let i = 1 ; i < rules . length ; i ++ ) {
125+ const rule = rules [ i ] as any ;
126+ if ( rule . variations && rule . key ) {
127+ heldExperiments . push ( rule . key ) ;
128+ }
129+ }
130+ }
131+
132+ return heldFeatures . length || heldExperiments . length
133+ ? { heldFeatures, heldExperiments }
134+ : null ;
135+ } , [ selectedExperiment ?. experiment , features ] ) ;
136+
137+ // Detect if this experiment is itself under a holdout
138+ const parentHoldout = useMemo ( ( ) => {
139+ const expFeatures = selectedExperiment ?. experiment ?. features ?? [ ] ;
140+ for ( const fid of expFeatures ) {
141+ const rule0 = ( features [ fid ] ?. rules ?? [ ] ) [ 0 ] as any ;
142+ const holdoutFid = rule0 ?. parentConditions ?. find (
143+ ( pc : { id ?: string } ) => pc . id ?. startsWith ( "$holdout:" ) ,
144+ ) ?. id ;
145+ if ( ! holdoutFid ) continue ;
146+ const holdoutExpKey = ( features [ holdoutFid ] ?. rules ?. [ 0 ] as any ) ?. key ;
147+ if ( holdoutExpKey ) return { holdoutFid, holdoutExpKey } ;
148+ }
149+ return null ;
150+ } , [ selectedExperiment ?. experiment , features ] ) ;
151+
94152 const [ viewEvaluationSource , setViewEvaluationSource ] = useState <
95153 string | undefined
96154 > ( undefined ) ;
@@ -196,7 +254,9 @@ export default function ExperimentDetail({
196254 < >
197255 < div className = "flex items-start gap-2" >
198256 < h2 className = "font-bold flex-1" >
199- { selectedExperiment ?. experiment ?. name || selectedEid }
257+ { selectedExperiment ?. experiment
258+ ? getExperimentDisplayName ( selectedExperiment . experiment )
259+ : selectedEid }
200260 </ h2 >
201261 < IconButton
202262 size = "3"
@@ -375,6 +435,69 @@ export default function ExperimentDetail({
375435 />
376436 ) : null }
377437
438+ { parentHoldout ? (
439+ < div className = "mt-3 mb-1" >
440+ < div className = "label font-semibold" > Holdout</ div >
441+ < Link
442+ size = "2"
443+ role = "button"
444+ href = "#"
445+ onClick = { ( e ) => {
446+ e . preventDefault ( ) ;
447+ setSelectedEid ( parentHoldout . holdoutExpKey ) ;
448+ setCurrentTab ( "experiments" ) ;
449+ } }
450+ >
451+ < PiFlaskFill className = "inline-block mr-1" size = { 12 } />
452+ { `Holdout Experiment (${ holdoutIdFromFid ( parentHoldout . holdoutFid ) } )` }
453+ </ Link >
454+ </ div >
455+ ) : null }
456+
457+ { holdoutMembers ? (
458+ < >
459+ < div className = "mt-4 mb-1 text-md font-semibold" >
460+ Held-out members
461+ </ div >
462+ < div className = "mb-4" >
463+ { holdoutMembers . heldFeatures . map ( ( fid ) => (
464+ < div key = { fid } >
465+ < Link
466+ size = "2"
467+ role = "button"
468+ href = "#"
469+ onClick = { ( e ) => {
470+ e . preventDefault ( ) ;
471+ setSelectedFid ( fid ) ;
472+ setCurrentTab ( "features" ) ;
473+ } }
474+ >
475+ < PiFlagFill className = "inline-block mr-1" size = { 12 } />
476+ { fid }
477+ </ Link >
478+ </ div >
479+ ) ) }
480+ { holdoutMembers . heldExperiments . map ( ( eid ) => (
481+ < div key = { eid } >
482+ < Link
483+ size = "2"
484+ role = "button"
485+ href = "#"
486+ onClick = { ( e ) => {
487+ e . preventDefault ( ) ;
488+ setSelectedEid ( eid ) ;
489+ setCurrentTab ( "experiments" ) ;
490+ } }
491+ >
492+ < PiFlaskFill className = "inline-block mr-1" size = { 12 } />
493+ { eid }
494+ </ Link >
495+ </ div >
496+ ) ) }
497+ </ div >
498+ </ >
499+ ) : null }
500+
378501 < div className = "mt-4 mb-1 text-md font-semibold" >
379502 Implementation
380503 { ( types ?. redirect ? 1 : 0 ) +
@@ -410,7 +533,7 @@ export default function ExperimentDetail({
410533 } }
411534 >
412535 < PiFlagFill className = "inline-block mr-1" size = { 12 } />
413- { fid }
536+ { formatExperimentKey ( fid ) }
414537 </ Link >
415538 </ div >
416539 ) ) }
@@ -604,9 +727,14 @@ export function getVariationSummary({
604727 const m = meta ?. [ i ] ;
605728 const { urlRedirect } = ( variation || { } ) as AutoExperimentVariation ;
606729
730+ const isHoldout = ! ! getHoldoutFeatureId ( experiment ) ;
731+ const holdoutDefaults = [ "Holdout" , "Treatment" ] ;
732+
607733 let title = `Variation ${ m ?. key ?? i } ` ;
608734 if ( m ?. name ) {
609735 title = m . name ;
736+ } else if ( isHoldout && holdoutDefaults [ i ] !== undefined ) {
737+ title = holdoutDefaults [ i ] ;
610738 } else if ( urlRedirect ) {
611739 title += `(${ urlRedirect } )` ;
612740 }
0 commit comments