Skip to content

Commit 87292d8

Browse files
committed
Ability to edit/change client context
Add functionality to dynamically modify the client context within the JEXL debugging tool.
1 parent ebe8b8e commit 87292d8

File tree

7 files changed

+256
-35
lines changed

7 files changed

+256
-35
lines changed

src/apis/nimbus.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
1313
ClientEnvironmentBase:
1414
"resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
1515
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
16+
FilterExpressions:
17+
"resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
1618
});
1719

1820
const { ExperimentManager } = ChromeUtils.importESModule(
@@ -21,9 +23,6 @@ const { ExperimentManager } = ChromeUtils.importESModule(
2123
const { ExperimentAPI, NimbusFeatures } = ChromeUtils.importESModule(
2224
"resource://nimbus/ExperimentAPI.sys.mjs",
2325
);
24-
const { TargetingContext } = ChromeUtils.importESModule(
25-
"resource://messaging-system/targeting/Targeting.sys.mjs",
26-
);
2726
const { AppConstants } = ChromeUtils.importESModule(
2827
"resource://gre/modules/AppConstants.sys.mjs",
2928
);
@@ -126,11 +125,7 @@ var nimbus = class extends ExtensionAPI {
126125

127126
async evaluateJEXL(expression, context = {}) {
128127
try {
129-
const targetingContext = await new TargetingContext(context, {});
130-
131-
const result = await targetingContext.evalWithDefault(expression);
132-
133-
return result;
128+
return await lazy.FilterExpressions.eval(expression, context);
134129
} catch (error) {
135130
console.error("Error evaluating expression:", error);
136131
throw error;

src/ui/.eslintrc.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,11 @@ module.exports = {
3434
checksVoidReturn: false,
3535
},
3636
],
37+
"@typescript-eslint/no-explicit-any": [
38+
"error",
39+
{
40+
ignoreRestArgs: true,
41+
},
42+
],
3743
},
3844
};

src/ui/components/ExperimentBrowserPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const ExperimentBrowserPage: FC = () => {
103103
addToast({
104104
message: `Id successfully generated and copied to clipboard. Test Id: ${result}`,
105105
variant: "success",
106-
autohide: true,
106+
autohide: false,
107107
});
108108
} else {
109109
addToast({ message: "Test Id generation failed", variant: "danger" });

src/ui/components/JEXLDebuggerPage.tsx

Lines changed: 232 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,164 @@ import { Container, Row, Col, Form, Button } from "react-bootstrap";
1010

1111
import { useToastsContext } from "../hooks/useToasts";
1212
import { evaluateJexl } from "../jexlParser";
13+
import { debounce } from "../utils/functional";
14+
15+
type ContextValue = object | string | boolean | number | Date;
16+
type FieldType = "object" | "string" | "boolean" | "number" | "Date";
17+
type FormDataValue = string | boolean;
18+
19+
type OnChangeFn = {
20+
(key: string, value: boolean, fieldType: "boolean"): void;
21+
(key: string, value: string, fieldType: Exclude<FieldType, "boolean">): void;
22+
};
23+
24+
type ContextFieldProps<TValue extends FormDataValue> = {
25+
fieldType: TValue extends boolean ? "boolean" : Exclude<FieldType, "boolean">;
26+
onChange: OnChangeFn;
27+
contextKey: string;
28+
value: TValue;
29+
};
30+
31+
const getFieldType = (value: ContextValue): FieldType => {
32+
switch (typeof value) {
33+
case "string":
34+
return "string";
35+
case "number":
36+
return "number";
37+
case "boolean":
38+
return "boolean";
39+
case "object":
40+
if (value instanceof Date) {
41+
return "Date";
42+
} else {
43+
return "object";
44+
}
45+
}
46+
};
47+
48+
function ContextField<TValue extends FormDataValue>({
49+
fieldType,
50+
onChange,
51+
contextKey,
52+
value,
53+
}: ContextFieldProps<TValue>) {
54+
const handleChange = useCallback(
55+
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
56+
if (fieldType === "boolean") {
57+
onChange(contextKey, (e.target as HTMLInputElement).checked, fieldType);
58+
} else {
59+
onChange(contextKey, e.target.value, fieldType);
60+
}
61+
},
62+
[onChange, contextKey, fieldType],
63+
);
64+
65+
switch (fieldType) {
66+
case "number":
67+
return (
68+
<Form.Control
69+
type="number"
70+
value={value as string}
71+
onChange={handleChange}
72+
className="p-3 w-50 grey-border short-text"
73+
/>
74+
);
75+
76+
case "string":
77+
return (
78+
<Form.Control
79+
type="text"
80+
value={value as string}
81+
onChange={handleChange}
82+
className="p-3 w-50 grey-border short-text"
83+
/>
84+
);
85+
86+
case "boolean":
87+
return (
88+
<Form.Check
89+
type="checkbox"
90+
checked={value as boolean}
91+
onChange={handleChange}
92+
className="p-3 w-50 ms-4 ps-5 mb-2 large-checkbox short-text"
93+
/>
94+
);
95+
96+
case "Date":
97+
return (
98+
<Form.Control
99+
type="datetime-local"
100+
step="1"
101+
value={value as string}
102+
onChange={handleChange}
103+
className="p-3 w-50 grey-border short-text"
104+
/>
105+
);
106+
107+
case "object":
108+
return (
109+
<Form.Control
110+
as="textarea"
111+
value={value as string}
112+
onChange={handleChange}
113+
className="p-3 w-50 grey-border long-text"
114+
placeholder="null"
115+
/>
116+
);
117+
118+
default:
119+
return null;
120+
}
121+
}
13122

14123
const JEXLDebuggerPage: FC = () => {
15-
const [clientContext, setClientContext] = useState({});
124+
const [originalContext, setOriginalContext] = useState<
125+
Record<string, ContextValue>
126+
>({});
127+
const [modifiedContext, setModifiedContext] = useState<
128+
Record<string, ContextValue>
129+
>({});
130+
const [formData, setFormData] = useState<Record<string, FormDataValue>>({});
16131
const [jexlExpression, setJexlExpression] = useState("");
17132
const [output, setOutput] = useState("");
18133
const { addToast } = useToastsContext();
19134

20135
const fetchClientContext = useCallback(async () => {
21136
try {
22-
const context = await browser.experiments.nimbus.getClientContext();
23-
setClientContext(context);
137+
const context =
138+
(await browser.experiments.nimbus.getClientContext()) as Record<
139+
string,
140+
ContextValue
141+
>;
142+
setOriginalContext(context);
143+
setModifiedContext({});
144+
setFormData(
145+
Object.fromEntries(
146+
Object.entries(context).map(([key, value]) => {
147+
let formValue: FormDataValue;
148+
const fieldType = getFieldType(value);
149+
150+
switch (fieldType) {
151+
case "string":
152+
formValue = value as string;
153+
break;
154+
case "boolean":
155+
formValue = value as boolean;
156+
break;
157+
case "number":
158+
formValue = (value as number).toString();
159+
break;
160+
case "Date":
161+
formValue = (value as Date).toISOString().slice(0, 19);
162+
break;
163+
case "object":
164+
formValue = JSON.stringify(value, null, 2);
165+
break;
166+
}
167+
return [key, formValue];
168+
}),
169+
),
170+
);
24171
} catch (error) {
25172
addToast({
26173
message: `Error fetching client context: ${(error as Error).message ?? String(error)}`,
@@ -45,7 +192,10 @@ const JEXLDebuggerPage: FC = () => {
45192
if (jexlExpression === "") {
46193
setOutput("Error evaluating expression");
47194
} else {
48-
const result = await evaluateJexl(jexlExpression, clientContext);
195+
const result = await evaluateJexl(jexlExpression, {
196+
...originalContext,
197+
...modifiedContext,
198+
});
49199
setOutput(result);
50200
}
51201
} catch (error) {
@@ -55,11 +205,78 @@ const JEXLDebuggerPage: FC = () => {
55205
variant: "danger",
56206
});
57207
}
58-
}, [jexlExpression, clientContext, addToast]);
208+
}, [jexlExpression, modifiedContext, originalContext, addToast]);
59209

60-
const memoizedClientContextEntries = useMemo(
61-
() => Object.entries(clientContext),
62-
[clientContext],
210+
const parseAndSetContext = useMemo(() => {
211+
return debounce((key: string, value: string, isNumber: boolean) => {
212+
if (isNumber) {
213+
const numVal = parseInt(value);
214+
if (!isNaN(numVal)) {
215+
setModifiedContext((prevContext) => ({
216+
...prevContext,
217+
[key]: numVal,
218+
}));
219+
} else {
220+
addToast({
221+
message: "Error changing context: Value entered must be a number",
222+
variant: "danger",
223+
});
224+
}
225+
} else {
226+
try {
227+
const parsedValue = JSON.parse(value) as ContextValue;
228+
setModifiedContext((prevContext) => ({
229+
...prevContext,
230+
[key]: parsedValue,
231+
}));
232+
} catch (error) {
233+
addToast({
234+
message: `Error changing context: ${(error as Error).message ?? String(error)}`,
235+
variant: "danger",
236+
});
237+
}
238+
}
239+
}, 1000);
240+
}, [addToast, setModifiedContext]);
241+
242+
const handleContextChange = useCallback<OnChangeFn>(
243+
(key: string, value: FormDataValue, fieldType: FieldType) => {
244+
switch (fieldType) {
245+
case "object":
246+
parseAndSetContext(key, String(value), false);
247+
break;
248+
249+
case "number":
250+
parseAndSetContext(key, String(value), true);
251+
break;
252+
253+
case "boolean":
254+
setModifiedContext((prevContext) => ({
255+
...prevContext,
256+
[key]: value,
257+
}));
258+
break;
259+
260+
case "Date":
261+
setModifiedContext((prevContext) => ({
262+
...prevContext,
263+
[key]: new Date(value as string),
264+
}));
265+
break;
266+
267+
case "string":
268+
setModifiedContext((prevContext) => ({
269+
...prevContext,
270+
[key]: value,
271+
}));
272+
break;
273+
}
274+
setFormData((prevContext) => ({
275+
...prevContext,
276+
[key]: value,
277+
}));
278+
},
279+
[setModifiedContext, setFormData, parseAndSetContext],
63280
);
64281

65282
return (
@@ -100,27 +317,18 @@ const JEXLDebuggerPage: FC = () => {
100317
>
101318
Refresh Context
102319
</Button>
103-
{memoizedClientContextEntries.map(([key, value]) => (
320+
{Object.entries(originalContext).map(([key]) => (
104321
<Row key={key} className="mb-4 d-flex align-items-center">
105322
<Col xs={3} className="secondary-fg fw-bold">
106323
{key}
107324
</Col>
108325
<Col xs={9}>
109-
{["number", "string", "boolean"].includes(typeof value) ? (
110-
<Form.Control
111-
type="text"
112-
readOnly
113-
value={String(value)}
114-
className="p-3 w-50 grey-border short-text"
115-
/>
116-
) : (
117-
<Form.Control
118-
as="textarea"
119-
readOnly
120-
value={JSON.stringify(value, null, 2)}
121-
className="p-3 w-50 grey-border long-text"
122-
/>
123-
)}
326+
<ContextField
327+
contextKey={key}
328+
value={formData[key]}
329+
onChange={handleContextChange}
330+
fieldType={getFieldType(originalContext[key])}
331+
/>
124332
</Col>
125333
</Row>
126334
))}

src/ui/components/Toasts.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function Toasts() {
1414
key={toast.id}
1515
bg={toast.variant}
1616
onClose={() => removeToast(toast.id)}
17-
autohide={!toast.autohide}
17+
autohide={toast.autohide}
1818
delay={3500}
1919
>
2020
<Toast.Header closeButton></Toast.Header>

src/ui/hooks/useToasts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default function useToasts(): UseToasts {
2525
const [toasts, setToasts] = useState<Toast[]>([]);
2626

2727
const addToast = useCallback(
28-
({ message, variant, autohide = false }: AddToastParams) => {
28+
({ message, variant, autohide = true }: AddToastParams) => {
2929
const id = window.crypto.randomUUID();
3030
setToasts((oldToasts) => [
3131
...oldToasts,

src/ui/utils/functional.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function debounce<F extends (...args: any[]) => void>(
2+
callback: F,
3+
delay: number,
4+
): (...args: Parameters<F>) => void {
5+
let timer: ReturnType<typeof setTimeout>;
6+
return function (...args: Parameters<F>): void {
7+
clearTimeout(timer);
8+
timer = setTimeout(() => {
9+
callback(...args);
10+
}, delay);
11+
};
12+
}

0 commit comments

Comments
 (0)