@@ -9,6 +9,17 @@ import { useDefinitions } from "../../services/DefinitionsContext";
99import track from "../../services/track" ;
1010import Toggle from "../Forms/Toggle" ;
1111import 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
1324export 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