Skip to content

Commit 0562961

Browse files
committed
Allow re-enrollment in feature configuration
This change checks for existing enrollments, and if present shows the user a modal asking whether they want to force enroll. If so, it unenrolls them from the previous one, and deletes it before proceeding with the new enrollment. Fixes #46
1 parent 87292d8 commit 0562961

File tree

4 files changed

+182
-37
lines changed

4 files changed

+182
-37
lines changed

src/apis/nimbus.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,14 @@ var nimbus = class extends ExtensionAPI {
4848
}
4949
},
5050

51-
async enrollWithFeatureConfig(featureId, featureValue, isRollout) {
51+
async enrollWithFeatureConfig(
52+
featureId,
53+
featureValue,
54+
isRollout,
55+
forceEnroll,
56+
) {
5257
try {
58+
const slug = `nimbus-devtools-${featureId}-${isRollout ? "rollout" : "experiment"}`;
5359
const recipe = JSON.parse(`{
5460
"bucketConfig": {
5561
"count": 1000,
@@ -74,16 +80,42 @@ var nimbus = class extends ExtensionAPI {
7480
"featureIds": [
7581
"${featureId}"
7682
],
77-
"slug": "nimbus-devtools-${featureId}-enrollment",
83+
"slug": "${slug}",
7884
"userFacingName": "Nimbus Devtools ${featureId} Enrollment",
7985
"userFacingDescription": "Testing the feature with feature ID: ${featureId}."
8086
}`);
8187

88+
const experimentStore = ExperimentManager.store.getAll();
89+
const slugExistsInStore = experimentStore.some(
90+
(experiment) => experiment.slug === recipe.slug,
91+
);
92+
const activeEnrollment =
93+
experimentStore.find(
94+
(experiment) =>
95+
experiment.featureIds.includes(featureId) &&
96+
experiment.active,
97+
)?.slug || null;
98+
99+
if (slugExistsInStore || activeEnrollment) {
100+
if (!forceEnroll) {
101+
return {
102+
enrolled: false,
103+
error: { slugExistsInStore, activeEnrollment },
104+
};
105+
}
106+
107+
if (activeEnrollment) {
108+
this.unenroll(activeEnrollment);
109+
}
110+
if (slugExistsInStore) {
111+
this.deleteInactiveEnrollment(slug);
112+
}
113+
}
82114
const result = await ExperimentManager.enroll(
83115
recipe,
84116
"nimbus-devtools",
85117
);
86-
return result !== null;
118+
return { enrolled: result !== null, error: null };
87119
} catch (error) {
88120
console.error(error);
89121
throw error;

src/apis/nimbus.json

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@
3838
{
3939
"name": "isRollout",
4040
"type": "boolean",
41-
"description": "An boolean representing whether or not this should be a rollout."
41+
"description": "A boolean representing whether or not this should be a rollout."
42+
},
43+
{
44+
"name": "forceEnroll",
45+
"type": "boolean",
46+
"description": "A boolean representing whether or not the enrollment should be forced."
4247
}
4348
]
4449
},
@@ -58,7 +63,6 @@
5863
"async": true,
5964
"parameters": []
6065
},
61-
6266
{
6367
"name": "setCollection",
6468
"type": "function",
@@ -72,7 +76,6 @@
7276
}
7377
]
7478
},
75-
7679
{
7780
"name": "evaluateJEXL",
7881
"type": "function",
@@ -93,15 +96,13 @@
9396
}
9497
]
9598
},
96-
9799
{
98100
"name": "getClientContext",
99101
"type": "function",
100102
"description": "Get the client context.",
101103
"async": true,
102104
"parameters": []
103105
},
104-
105106
{
106107
"name": "updateRecipes",
107108
"type": "function",
@@ -115,7 +116,6 @@
115116
}
116117
]
117118
},
118-
119119
{
120120
"name": "forceEnroll",
121121
"type": "function",
@@ -135,15 +135,13 @@
135135
}
136136
]
137137
},
138-
139138
{
140139
"name": "getExperimentStore",
141140
"type": "function",
142141
"description": "Get all enrollments from the experiment store.",
143142
"async": true,
144143
"parameters": []
145144
},
146-
147145
{
148146
"name": "unenroll",
149147
"type": "function",
@@ -157,7 +155,6 @@
157155
}
158156
]
159157
},
160-
161158
{
162159
"name": "deleteInactiveEnrollment",
163160
"type": "function",
@@ -171,7 +168,6 @@
171168
}
172169
]
173170
},
174-
175171
{
176172
"name": "generateTestIds",
177173
"type": "function",

src/types/global.d.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,27 @@ declare module "mozjexl/lib/parser/Parser" {
5858
}
5959

6060
declare namespace browser.experiments.nimbus {
61+
type EnrollWithFeatureConfigResult =
62+
| {
63+
enrolled: true;
64+
error: null;
65+
}
66+
| {
67+
enrolled: false;
68+
error: {
69+
slugExistsInStore: boolean;
70+
activeEnrollment: string | null;
71+
};
72+
};
73+
6174
function enrollInExperiment(jsonData: object): Promise<boolean>;
6275

6376
function enrollWithFeatureConfig(
6477
featureId: string,
6578
featureValue: object,
6679
isRollout: boolean,
67-
): Promise<boolean>;
80+
forceEnroll: boolean,
81+
): Promise<EnrollWithFeatureConfigResult>;
6882

6983
function getFeatureConfigs(): Promise<string[]>;
7084

src/ui/components/FeatureConfigPage.tsx

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,36 @@
1-
import { ChangeEvent, FC, useState, useCallback } from "react";
2-
import { Form, Container, Button, Row, Col } from "react-bootstrap";
1+
import { ChangeEvent, FC, useState, useCallback, useMemo } from "react";
2+
import { Form, Container, Button, Row, Col, Modal } from "react-bootstrap";
33

44
import { useToastsContext } from "../hooks/useToasts";
55
import DropdownMenu from "./DropdownMenu";
66

7+
type EnrollWithFeatureConfigResult =
8+
| {
9+
enrolled: true;
10+
error: null;
11+
}
12+
| {
13+
enrolled: false;
14+
error: {
15+
slugExistsInStore: boolean;
16+
activeEnrollment: string | null;
17+
};
18+
};
19+
720
const FeatureConfigPage: FC = () => {
821
const [jsonInput, setJsonInput] = useState("");
922
const [selectedFeatureId, setSelectedFeatureId] = useState("");
1023
const [isRollout, setIsRollout] = useState(false);
24+
const [enrollError, setEnrollError] =
25+
useState<EnrollWithFeatureConfigResult["error"]>(null);
1126
const { addToast } = useToastsContext();
1227

28+
const slug = useMemo(() => {
29+
return `nimbus-devtools-${selectedFeatureId}-${
30+
isRollout ? "rollout" : "experiment"
31+
}`;
32+
}, [selectedFeatureId, isRollout]);
33+
1334
const handleInputChange = useCallback(
1435
(event: ChangeEvent<HTMLTextAreaElement>) => {
1536
setJsonInput(event.target.value);
@@ -28,32 +49,99 @@ const FeatureConfigPage: FC = () => {
2849
[],
2950
);
3051

31-
const handleEnrollClick = useCallback(async () => {
32-
if (selectedFeatureId === "") {
33-
addToast({ message: "Invalid Input: Select feature", variant: "danger" });
34-
} else if (jsonInput === "") {
35-
addToast({ message: "Invalid Input: Enter JSON", variant: "danger" });
36-
} else {
37-
try {
38-
const result = await browser.experiments.nimbus.enrollWithFeatureConfig(
39-
selectedFeatureId,
40-
JSON.parse(jsonInput) as object,
41-
isRollout,
42-
);
43-
44-
if (result) {
45-
addToast({ message: "Enrollment successful", variant: "success" });
46-
} else {
47-
addToast({ message: "Enrollment failed", variant: "danger" });
48-
}
49-
} catch (error) {
52+
const handleEnrollClick = useCallback(
53+
async (
54+
event?: React.MouseEvent<HTMLButtonElement>,
55+
forceEnroll = false,
56+
) => {
57+
event?.preventDefault();
58+
59+
if (selectedFeatureId === "") {
5060
addToast({
51-
message: `Error enrolling into experiment: ${(error as Error).message ?? String(error)}`,
61+
message: "Invalid Input: Select feature",
5262
variant: "danger",
5363
});
64+
} else if (jsonInput === "") {
65+
addToast({ message: "Invalid Input: Enter JSON", variant: "danger" });
66+
} else {
67+
try {
68+
const result: EnrollWithFeatureConfigResult =
69+
await browser.experiments.nimbus.enrollWithFeatureConfig(
70+
selectedFeatureId,
71+
JSON.parse(jsonInput) as object,
72+
isRollout,
73+
forceEnroll,
74+
);
75+
76+
if (result.enrolled) {
77+
addToast({ message: "Enrollment successful", variant: "success" });
78+
} else if (result.error) {
79+
setEnrollError(result.error);
80+
}
81+
} catch (error) {
82+
addToast({
83+
message: `Error enrolling into experiment: ${
84+
(error as Error).message ?? String(error)
85+
}`,
86+
variant: "danger",
87+
});
88+
}
5489
}
90+
},
91+
[jsonInput, selectedFeatureId, isRollout, addToast],
92+
);
93+
94+
const handleModalConfirm = useCallback(async () => {
95+
setEnrollError(null);
96+
await handleEnrollClick(null, true);
97+
}, [handleEnrollClick, setEnrollError]);
98+
99+
const handleModalClose = useCallback(() => {
100+
setEnrollError(null);
101+
}, [setEnrollError]);
102+
103+
function EnrollmentError(
104+
slug: string,
105+
enrollError: EnrollWithFeatureConfigResult["error"],
106+
) {
107+
if (!enrollError) {
108+
return null;
109+
}
110+
const { activeEnrollment, slugExistsInStore } = enrollError;
111+
112+
if (activeEnrollment && slugExistsInStore) {
113+
return (
114+
<p>
115+
There already is an enrollment for the feature:{" "}
116+
<strong>{slug}</strong>. Would you like to proceed with force
117+
enrollment by unenrolling, deleting, and re-enrolling into the new
118+
configuration?
119+
</p>
120+
);
121+
}
122+
123+
if (activeEnrollment) {
124+
return (
125+
<p>
126+
There is an active enrollment for the feature: <strong>{slug}</strong>
127+
. Would you like to unenroll from the active enrollment and re-enroll
128+
into the new configuration?
129+
</p>
130+
);
131+
}
132+
133+
if (slugExistsInStore) {
134+
return (
135+
<p>
136+
There is an inactive enrollment stored for the feature:{" "}
137+
<strong>{slug}</strong>. Would you like to delete the inactive
138+
enrollment and re-enroll into the new configuration?
139+
</p>
140+
);
55141
}
56-
}, [jsonInput, selectedFeatureId, isRollout, addToast]);
142+
143+
return null;
144+
}
57145

58146
return (
59147
<Container className="main-content p-2 overflow-hidden">
@@ -90,6 +178,21 @@ const FeatureConfigPage: FC = () => {
90178
Enroll
91179
</Button>
92180
</Form>
181+
182+
<Modal show={!!enrollError} onHide={handleModalClose}>
183+
<Modal.Header closeButton>
184+
<Modal.Title>Force Enrollment</Modal.Title>
185+
</Modal.Header>
186+
<Modal.Body>{EnrollmentError(slug, enrollError)}</Modal.Body>
187+
<Modal.Footer>
188+
<Button variant="secondary" onClick={handleModalClose}>
189+
Cancel
190+
</Button>
191+
<Button variant="danger" onClick={handleModalConfirm}>
192+
Force Enroll
193+
</Button>
194+
</Modal.Footer>
195+
</Modal>
93196
</Container>
94197
);
95198
};

0 commit comments

Comments
 (0)