Skip to content

Commit d94a6e9

Browse files
authored
When creating feature, option to add initial override rule. (growthbook#240)
1 parent e4840a5 commit d94a6e9

File tree

7 files changed

+611
-251
lines changed

7 files changed

+611
-251
lines changed

packages/front-end/components/Features/ConditionInput.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,6 @@ export default function ConditionInput(props: Props) {
218218
type="button"
219219
onClick={(e) => {
220220
e.preventDefault();
221-
console.log("Click delete");
222221
const newConds = [...conds];
223222
newConds.splice(i, 1);
224223
setConds(newConds);

packages/front-end/components/Features/FeatureModal.tsx

Lines changed: 241 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ import { useDefinitions } from "../../services/DefinitionsContext";
99
import track from "../../services/track";
1010
import Toggle from "../Forms/Toggle";
1111
import uniq from "lodash/uniq";
12+
import RadioSelector from "../Forms/RadioSelector";
13+
import ConditionInput from "./ConditionInput";
14+
import useOrgSettings from "../../hooks/useOrgSettings";
15+
import {
16+
getDefaultRuleValue,
17+
getDefaultValue,
18+
getDefaultVariationValue,
19+
validateFeatureRule,
20+
} from "../../services/features";
21+
import RolloutPercentInput from "./RolloutPercentInput";
22+
import VariationsInput from "./VariationsInput";
1223

1324
export type Props = {
1425
close: () => void;
@@ -41,25 +52,38 @@ export default function FeatureModal({ close, existing, onSuccess }: Props) {
4152
const form = useForm<Partial<FeatureInterface>>({
4253
defaultValues: {
4354
valueType: existing?.valueType || "boolean",
44-
defaultValue: existing?.defaultValue ?? "true",
55+
defaultValue:
56+
existing?.defaultValue ??
57+
getDefaultValue(existing?.valueType || "boolean"),
4558
description: existing?.description || "",
4659
id: existing?.id || "",
4760
project: existing?.project ?? project,
4861
environments: ["dev"],
62+
rules: [],
4963
},
5064
});
5165
const { apiCall } = useAuth();
66+
const settings = useOrgSettings();
67+
const hasHashAttributes =
68+
settings?.attributeSchema?.filter((x) => x.hashAttribute)?.length > 0;
5269

5370
const valueType = form.watch("valueType");
5471
const environments = form.watch("environments");
5572

73+
const rules = form.watch("rules");
74+
const rule = rules?.[0];
75+
5676
return (
5777
<Modal
5878
open={true}
5979
size="lg"
6080
header="Create Feature"
6181
close={close}
6282
submit={form.handleSubmit(async (values) => {
83+
if (values.rules.length > 0) {
84+
validateFeatureRule(values.rules[0], valueType);
85+
}
86+
6387
const body = {
6488
...values,
6589
defaultValue: parseDefaultValue(
@@ -70,8 +94,6 @@ export default function FeatureModal({ close, existing, onSuccess }: Props) {
7094

7195
if (existing) {
7296
delete body.id;
73-
} else {
74-
body.rules = [];
7597
}
7698

7799
const res = await apiCall<{ feature: FeatureInterface }>(
@@ -86,7 +108,17 @@ export default function FeatureModal({ close, existing, onSuccess }: Props) {
86108
track("Feature Created", {
87109
valueType: values.valueType,
88110
hasDescription: values.description.length > 0,
111+
initialRule: values.rules?.[0]?.type ?? "none",
89112
});
113+
if (values.rules?.length > 0) {
114+
track("Save Feature Rule", {
115+
source: "create-feature",
116+
ruleIndex: 0,
117+
type: values.rules[0].type,
118+
hasCondition: values.rules[0].condition.length > 2,
119+
hasDescription: false,
120+
});
121+
}
90122
}
91123

92124
await onSuccess(res.feature);
@@ -99,34 +131,22 @@ export default function FeatureModal({ close, existing, onSuccess }: Props) {
99131
pattern="^[a-zA-Z0-9_.:|-]+$"
100132
required
101133
disabled={!!existing}
134+
readOnly={!!existing}
102135
title="Only letters, numbers, and the characters '_-.:|' allowed. No spaces."
103136
helpText={
104137
<>
105-
Only letters, numbers, and the characters <code>_-.:|</code>{" "}
138+
Only letters, numbers, and the characters <code>_</code>,{" "}
139+
<code>-</code>, <code>.</code>, <code>:</code>, and <code>|</code>{" "}
106140
allowed. No spaces. <strong>Cannot be changed later!</strong>
107141
</>
108142
}
109143
/>
110144
)}
111145

112-
<Field
113-
label="Value Type"
114-
{...form.register("valueType")}
115-
options={[
116-
{
117-
display: "boolean (on/off)",
118-
value: "boolean",
119-
},
120-
"number",
121-
"string",
122-
"json",
123-
]}
124-
/>
125-
126146
<label>Enabled Environments</label>
127147
<div className="row">
128148
<div className="col-auto">
129-
<div className="form-group">
149+
<div className="form-group mb-0">
130150
<label htmlFor={"dev_toggle_create"} className="mr-2 ml-3">
131151
Dev:
132152
</label>
@@ -144,7 +164,7 @@ export default function FeatureModal({ close, existing, onSuccess }: Props) {
144164
</div>
145165
</div>
146166
<div className="col-auto">
147-
<div className="form-group">
167+
<div className="form-group mb-0">
148168
<label htmlFor={"production_toggle_create"} className="mr-2">
149169
Production:
150170
</label>
@@ -163,17 +183,208 @@ export default function FeatureModal({ close, existing, onSuccess }: Props) {
163183
</div>
164184
</div>
165185

166-
<FeatureValueField
167-
label="Value When Enabled"
168-
form={form}
169-
field="defaultValue"
170-
valueType={valueType}
171-
helpText={
172-
existing
173-
? ""
174-
: "After creating the feature, you will be able to add rules to override this default"
175-
}
186+
<hr />
187+
<h5>When Enabled</h5>
188+
189+
<Field
190+
label="Value Type"
191+
value={valueType}
192+
onChange={(e) => {
193+
const val = e.target.value as FeatureValueType;
194+
const defaultValue = getDefaultValue(val);
195+
form.setValue("valueType", val);
196+
197+
// Update values in rest of modal
198+
if (!rule) {
199+
form.setValue("defaultValue", defaultValue);
200+
} else if (rule.type === "force") {
201+
const otherVal = getDefaultVariationValue(defaultValue);
202+
form.setValue("defaultValue", otherVal);
203+
form.setValue("rules.0.value", defaultValue);
204+
} else if (rule.type === "rollout") {
205+
const otherVal = getDefaultVariationValue(defaultValue);
206+
form.setValue("defaultValue", otherVal);
207+
form.setValue("rules.0.value", defaultValue);
208+
} else if (rule.type === "experiment") {
209+
const otherVal = getDefaultVariationValue(defaultValue);
210+
form.setValue("defaultValue", otherVal);
211+
212+
if (val === "boolean") {
213+
form.setValue("rules.0.values", [
214+
{
215+
value: otherVal,
216+
weight: 0.5,
217+
},
218+
{
219+
value: defaultValue,
220+
weight: 0.5,
221+
},
222+
]);
223+
} else {
224+
for (let i = 0; i < rule.values.length; i++) {
225+
form.setValue(
226+
`rules.0.values.${i}.value`,
227+
i ? defaultValue : otherVal
228+
);
229+
}
230+
}
231+
}
232+
}}
233+
options={[
234+
{
235+
display: "boolean (on/off)",
236+
value: "boolean",
237+
},
238+
"number",
239+
"string",
240+
"json",
241+
]}
176242
/>
243+
244+
<div className="form-group">
245+
<label>
246+
Behavior <small className="text-muted">(can change later)</small>
247+
</label>
248+
<RadioSelector
249+
name="ruleType"
250+
value={rules?.[0]?.type || ""}
251+
labelWidth={145}
252+
options={[
253+
{
254+
key: "",
255+
display: "Simple",
256+
description: "All users get the same value",
257+
},
258+
{
259+
key: "force",
260+
display: "Targeted",
261+
description:
262+
"Most users get one value, a targeted segment gets another",
263+
},
264+
{
265+
key: "rollout",
266+
display: "Percentage Rollout",
267+
description:
268+
"Gradually release a value to users while everyone else gets a fallback",
269+
},
270+
{
271+
key: "experiment",
272+
display: "A/B Experiment",
273+
description: "Run an A/B test between multiple values.",
274+
},
275+
]}
276+
setValue={(value) => {
277+
let defaultValue = getDefaultValue(valueType);
278+
279+
if (!value) {
280+
form.setValue("rules", []);
281+
form.setValue("defaultValue", defaultValue);
282+
} else {
283+
defaultValue = getDefaultVariationValue(defaultValue);
284+
form.setValue("defaultValue", defaultValue);
285+
form.setValue("rules", [
286+
{
287+
...getDefaultRuleValue({
288+
defaultValue: defaultValue,
289+
ruleType: value,
290+
attributeSchema: settings?.attributeSchema,
291+
}),
292+
},
293+
]);
294+
}
295+
}}
296+
/>
297+
</div>
298+
299+
{!rule ? (
300+
<FeatureValueField
301+
label={"Value"}
302+
form={form}
303+
field="defaultValue"
304+
valueType={valueType}
305+
/>
306+
) : rule?.type === "rollout" ? (
307+
<>
308+
<Field
309+
label="Sample users based on attribute"
310+
{...form.register("rules.0.hashAttribute")}
311+
options={settings.attributeSchema
312+
.filter((s) => !hasHashAttributes || s.hashAttribute)
313+
.map((s) => s.property)}
314+
helpText="Will be hashed together with the feature key to determine if user is part of the rollout"
315+
/>
316+
<RolloutPercentInput
317+
value={form.watch("rules.0.coverage")}
318+
setValue={(n) => {
319+
form.setValue("rules.0.coverage", n);
320+
}}
321+
label="Percent of users to include"
322+
/>
323+
<FeatureValueField
324+
label={"Value when included"}
325+
form={form}
326+
field="rules.0.value"
327+
valueType={valueType}
328+
/>
329+
<FeatureValueField
330+
label={"Fallback value"}
331+
form={form}
332+
field="defaultValue"
333+
valueType={valueType}
334+
/>
335+
</>
336+
) : rule?.type === "force" ? (
337+
<>
338+
<ConditionInput
339+
defaultValue={rule?.condition}
340+
onChange={(cond) => {
341+
form.setValue("rules.0.condition", cond);
342+
}}
343+
/>
344+
<FeatureValueField
345+
label={"Value When Targeted"}
346+
form={form}
347+
field="rules.0.value"
348+
valueType={valueType}
349+
/>
350+
<FeatureValueField
351+
label={"Fallback Value"}
352+
form={form}
353+
field="defaultValue"
354+
valueType={valueType}
355+
/>
356+
</>
357+
) : (
358+
<>
359+
<Field
360+
label="Tracking Key"
361+
{...form.register(`rules.0.trackingKey`)}
362+
placeholder={form.watch("id")}
363+
helpText="Unique identifier for this experiment, used to track impressions and analyze results"
364+
/>
365+
<Field
366+
label="Sample users based on attribute"
367+
{...form.register("rules.0.hashAttribute")}
368+
options={settings.attributeSchema
369+
.filter((s) => !hasHashAttributes || s.hashAttribute)
370+
.map((s) => s.property)}
371+
helpText="Will be hashed together with the Tracking Key to pick a value"
372+
/>
373+
<VariationsInput
374+
form={form}
375+
formPrefix="rules.0."
376+
defaultValue={rule?.values?.[0]?.value}
377+
valueType={valueType}
378+
/>
379+
<FeatureValueField
380+
label={"Fallback Value"}
381+
helpText={"For people excluded from the experiment"}
382+
form={form}
383+
field="defaultValue"
384+
valueType={valueType}
385+
/>
386+
</>
387+
)}
177388
</Modal>
178389
);
179390
}

0 commit comments

Comments
 (0)