Skip to content

Commit a1fe761

Browse files
support holdouts in ui (#105)
1 parent 406f1ef commit a1fe761

File tree

5 files changed

+263
-20
lines changed

5 files changed

+263
-20
lines changed

src/app/components/ExperimentDetail.tsx

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
2627
import React, { CSSProperties, useEffect, useMemo, useState } from "react";
2728
import {
2829
ExperimentWithFeatures,
30+
formatExperimentKey,
31+
getExperimentDisplayName,
32+
getHoldoutFeatureId,
33+
holdoutIdFromFid,
2934
HEADER_H,
3035
LEFT_PERCENT,
3136
} from "./ExperimentsTab";
3237
import useGlobalState from "@/app/hooks/useGlobalState";
3338
import { APP_ORIGIN, CLOUD_APP_ORIGIN } from "@/app/components/Settings";
3439
import useTabState from "@/app/hooks/useTabState";
3540
import { SelectedExperiment } from "@/app/components/ExperimentsTab";
36-
import { AutoExperimentVariation, isURLTargeted } from "@growthbook/growthbook";
41+
import { AutoExperimentVariation, FeatureDefinition, isURLTargeted } from "@growthbook/growthbook";
3742
import clsx from "clsx";
3843
import DebugLogger, { DebugLogAccordion } from "@/app/components/DebugLogger";
3944
import { 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
}

src/app/components/ExperimentsTab.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ export default function ExperimentsTab() {
291291
<div
292292
className="title line-clamp-1 pl-2.5 pr-8"
293293
style={{ width: fullWidthListView ? col1 : undefined }}
294+
title={getExperimentDisplayName(experiment)}
294295
>
295296
<FeatureExperimentStatusIcon
296297
evaluated={pageEvaluatedExperiments.has(eid)}
@@ -303,7 +304,7 @@ export default function ExperimentsTab() {
303304
size={12}
304305
/>
305306
) : null}
306-
{eid}
307+
{getExperimentDisplayName(experiment)}
307308
</div>
308309
<div
309310
className={clsx("flex items-center flex-shrink-0 text-sm", {
@@ -478,3 +479,35 @@ export function getFeatureExperiments(
478479
}
479480
return experiments;
480481
}
482+
483+
// Extracts the holdout ID from a $holdout:<id> feature ID, or returns undefined
484+
export function holdoutIdFromFid(fid: string): string | undefined {
485+
const match = fid.match(/^\$holdout:(.+)$/);
486+
return match ? match[1] : undefined;
487+
}
488+
489+
// Used to prettify $holdout:<id> feature IDs (feature list, detail header, Implementation link)
490+
export function formatExperimentKey(key: string): string {
491+
const holdoutId = holdoutIdFromFid(key);
492+
if (holdoutId) {
493+
return `Holdout generated feature (${holdoutId})`;
494+
}
495+
return key;
496+
}
497+
498+
export function getHoldoutFeatureId(
499+
experiment: ExperimentWithFeatures,
500+
): string | undefined {
501+
return experiment.features?.find((f) => f.startsWith("$holdout:"));
502+
}
503+
504+
export function getExperimentDisplayName(
505+
experiment: ExperimentWithFeatures,
506+
): string {
507+
if (experiment.name) return experiment.name;
508+
const holdoutFid = getHoldoutFeatureId(experiment);
509+
if (holdoutFid) {
510+
return `Holdout (${holdoutIdFromFid(holdoutFid)})`;
511+
}
512+
return experiment.key;
513+
}

0 commit comments

Comments
 (0)