@khanacademy/perseus 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/article-renderer.d.ts +2 -1
  3. package/dist/dependencies.d.ts +7 -1
  4. package/dist/es/index.js +66 -29
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.js +66 -29
  7. package/dist/index.js.map +1 -1
  8. package/dist/interaction-tracker.d.ts +3 -3
  9. package/dist/item-renderer.d.ts +2 -1
  10. package/dist/multi-items/multi-renderer.d.ts +2 -1
  11. package/dist/perseus-api.d.ts +1 -1
  12. package/dist/renderer.d.ts +1 -1
  13. package/dist/server-item-renderer.d.ts +3 -1
  14. package/dist/types.d.ts +29 -9
  15. package/dist/widgets/expression.d.ts +409 -12
  16. package/dist/widgets/graded-group.d.ts +2 -2
  17. package/dist/widgets/sequence.d.ts +2 -2
  18. package/package.json +3 -3
  19. package/src/__stories__/article-renderer.stories.tsx +35 -2
  20. package/src/__testdata__/article-renderer.testdata.ts +71 -0
  21. package/src/__tests__/item-renderer.test.tsx +12 -1
  22. package/src/__tests__/server-item-renderer.test.tsx +9 -1
  23. package/src/article-renderer.tsx +38 -30
  24. package/src/dependencies.ts +16 -1
  25. package/src/interaction-tracker.ts +3 -3
  26. package/src/item-renderer.tsx +62 -44
  27. package/src/multi-items/__tests__/multi-renderer.test.tsx +6 -1
  28. package/src/multi-items/multi-renderer.tsx +16 -4
  29. package/src/renderer.tsx +1 -1
  30. package/src/server-item-renderer.tsx +21 -16
  31. package/src/types.ts +49 -13
  32. package/src/widgets/__stories__/definition.stories.tsx +8 -1
  33. package/src/widgets/__stories__/expression.stories.tsx +2 -2
  34. package/src/widgets/__tests__/{expression.test.ts → expression.test.tsx} +73 -8
  35. package/src/widgets/__tests__/renderQuestion.tsx +26 -18
  36. package/src/widgets/expression.tsx +82 -48
  37. package/src/widgets/graded-group.tsx +11 -2
  38. package/src/widgets/sequence.tsx +10 -2
  39. package/tsconfig-build.tsbuildinfo +1 -1
@@ -12,6 +12,7 @@ import * as React from "react";
12
12
  import _ from "underscore";
13
13
 
14
14
  import AssetContext from "./asset-context";
15
+ import {DependenciesContext} from "./dependencies";
15
16
  import HintsRenderer from "./hints-renderer";
16
17
  import Objective from "./interactive2/objective_";
17
18
  import LoadingContext from "./loading-context";
@@ -20,7 +21,7 @@ import Renderer from "./renderer";
20
21
  import Util from "./util";
21
22
 
22
23
  import type {KeypadProps} from "./mixins/provide-keypad";
23
- import type {APIOptions, FocusPath} from "./types";
24
+ import type {APIOptions, FocusPath, PerseusDependenciesV2} from "./types";
24
25
  import type {RendererInterface, KEScore} from "@khanacademy/perseus-core";
25
26
 
26
27
  const {mapObject} = Objective;
@@ -37,6 +38,8 @@ type OwnProps = // These props are used by the ProvideKeypad mixin.
37
38
  reviewMode?: boolean;
38
39
  // from KeypadContext
39
40
  keypadElement?: any | null | undefined;
41
+
42
+ dependencies: PerseusDependenciesV2;
40
43
  };
41
44
 
42
45
  type HOCProps = {
@@ -425,22 +428,24 @@ export class ServerItemRenderer
425
428
  );
426
429
 
427
430
  return (
428
- <div>
429
- <div>{questionRenderer}</div>
430
- <div
431
- className={
432
- // Avoid adding any horizontal padding when applying the
433
- // mobile hint styles, which are flush to the left.
434
- // NOTE(charlie): We may still want to apply this
435
- // padding for desktop exercises.
436
- apiOptions.isMobile
437
- ? undefined
438
- : css(styles.hintsContainer)
439
- }
440
- >
441
- {hintsRenderer}
431
+ <DependenciesContext.Provider value={this.props.dependencies}>
432
+ <div>
433
+ <div>{questionRenderer}</div>
434
+ <div
435
+ className={
436
+ // Avoid adding any horizontal padding when applying the
437
+ // mobile hint styles, which are flush to the left.
438
+ // NOTE(charlie): We may still want to apply this
439
+ // padding for desktop exercises.
440
+ apiOptions.isMobile
441
+ ? undefined
442
+ : css(styles.hintsContainer)
443
+ }
444
+ >
445
+ {hintsRenderer}
446
+ </div>
442
447
  </div>
443
- </div>
448
+ </DependenciesContext.Provider>
444
449
  );
445
450
  }
446
451
  }
package/src/types.ts CHANGED
@@ -4,12 +4,15 @@ import type {Item} from "./multi-items/item-types";
4
4
  import type {PerseusWidget} from "./perseus-types";
5
5
  import type {SizeClass} from "./util/sizing-utils";
6
6
  import type {KeypadAPI} from "@khanacademy/math-input";
7
- import type {SendEventFn} from "@khanacademy/perseus-core";
7
+ import type {AnalyticsEventHandlerFn} from "@khanacademy/perseus-core";
8
8
  import type {Result} from "@khanacademy/wonder-blocks-data";
9
9
  import type * as React from "react";
10
10
 
11
11
  export type FocusPath = ReadonlyArray<string> | null | undefined;
12
12
 
13
+ // TODO(FEI-5054): Figure out how to get global .d.ts files working with monorepos
14
+ type Empty = Record<never, never>;
15
+
13
16
  export type Dimensions = {
14
17
  width?: number;
15
18
  height?: number;
@@ -109,6 +112,20 @@ export type Path = ReadonlyArray<string>;
109
112
 
110
113
  type StubTagEditorType = any; // from "./components/stub-tag-editor";
111
114
 
115
+ type TrackInteractionArgs = {
116
+ // The widget type that this interaction originates from
117
+ type: string;
118
+ // The widget id that this interaction originates from
119
+ id: string;
120
+
121
+ correct?: boolean;
122
+
123
+ // Tracking args are all optional here because we don't know which
124
+ // widgets originated the call, and thus can't know what extra
125
+ // arguments will be included!
126
+ } & Partial<TrackingGradedGroupExtraArguments> &
127
+ Partial<TrackingSequenceExtraArguments>;
128
+
112
129
  // APIOptions provides different ways to customize the behaviour of Perseus.
113
130
  export type APIOptions = Readonly<{
114
131
  isArticle?: boolean;
@@ -162,15 +179,7 @@ export type APIOptions = Readonly<{
162
179
  // A function that is called when the user has interacted with a widget. It
163
180
  // also includes any extra parameters that the originating widget provided.
164
181
  // This is used for keeping track of widget interactions.
165
- trackInteraction?: (
166
- args: {
167
- // The widget type that this interaction originates from
168
- type: string;
169
- // The widget id that this interaction originates from
170
- id: string;
171
- correct?: boolean;
172
- } & Record<string, unknown>,
173
- ) => void;
182
+ trackInteraction?: (args: TrackInteractionArgs) => void;
174
183
  // A boolean that indicates whether or not a custom keypad is
175
184
  // being used. For mobile web this will be the ProvidedKeypad
176
185
  // component. In this situation we use the MathInput component
@@ -310,7 +319,6 @@ export type PerseusDependencies = {
310
319
  //misc
311
320
  staticUrl: StaticUrlFn;
312
321
  InitialRequestUrl: InitialRequestUrlInterface;
313
- analytics: SendEventFn;
314
322
 
315
323
  // video widget
316
324
  // This is used as a hook to fetch data about a video which is used to
@@ -332,6 +340,18 @@ export type PerseusDependencies = {
332
340
  isMobile: boolean;
333
341
  };
334
342
 
343
+ /**
344
+ * The modern iteration of Perseus Depedndencies. These dependencies are
345
+ * provided to Perseus through its entrypoints (for example:
346
+ * ServerItemRenderer) and then attached to the DependenciesContext so they are
347
+ * available anywhere down the React render tree.
348
+ *
349
+ * Prefer using this type over `PerseusDependencies` when possible.
350
+ */
351
+ export type PerseusDependenciesV2 = {
352
+ analytics: {onAnalyticsEvent: AnalyticsEventHandlerFn};
353
+ };
354
+
335
355
  export type APIOptionsWithDefaults = Readonly<
336
356
  APIOptions & {
337
357
  GroupMetadataEditor: NonNullable<APIOptions["GroupMetadataEditor"]>;
@@ -369,6 +389,16 @@ export type Tracking =
369
389
  // Track all interactions
370
390
  | "all";
371
391
 
392
+ // See graded-group widget
393
+ export type TrackingGradedGroupExtraArguments = {
394
+ status: "correct" | "incorrect" | "invalid";
395
+ };
396
+
397
+ // See sequence widget
398
+ export type TrackingSequenceExtraArguments = {
399
+ visible: number;
400
+ };
401
+
372
402
  export type Alignment =
373
403
  | "default"
374
404
  | "block"
@@ -443,7 +473,13 @@ export type FilterCriterion =
443
473
  ) => boolean);
444
474
 
445
475
  // NOTE: Rubric should always be the corresponding widget options type for the component.
446
- export type WidgetProps<RenderProps, Rubric> = RenderProps & {
476
+ export type WidgetProps<
477
+ RenderProps,
478
+ Rubric,
479
+ // Defines the arguments that can be passed to the `trackInteraction`
480
+ // function from APIOptions for this widget.
481
+ TrackingExtraArgs = Empty,
482
+ > = RenderProps & {
447
483
  // provided by renderer.jsx#getWidgetProps()
448
484
  widgetId: string;
449
485
  alignment: string | null | undefined;
@@ -463,7 +499,7 @@ export type WidgetProps<RenderProps, Rubric> = RenderProps & {
463
499
  // APIOptions. This provides the widget an easy way to notify the renderer
464
500
  // of an interaction. The Renderer then enriches the data provided with the
465
501
  // widget's id and type before calling APIOptions.trackInteraction.
466
- trackInteraction: (extraData?: any) => void;
502
+ trackInteraction: (extraData?: TrackingExtraArgs) => void;
467
503
  isLastUsedWidget: boolean;
468
504
  // provided by widget-container.jsx#render()
469
505
  linterContext: LinterContextProps;
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
 
3
3
  import {RendererWithDebugUI} from "../../../../../testing/renderer-with-debug-ui";
4
+ import {storybookDependenciesV2} from "../../../../../testing/test-dependencies";
4
5
  import ArticleRenderer from "../../article-renderer";
5
6
 
6
7
  export default {
@@ -103,5 +104,11 @@ export const MultipleDefinitions = (args: StoryArgs): React.ReactElement => {
103
104
  };
104
105
 
105
106
  export const ArticleDefintion = (args: StoryArgs): React.ReactElement => {
106
- return <ArticleRenderer json={article} useNewStyles />;
107
+ return (
108
+ <ArticleRenderer
109
+ json={article}
110
+ useNewStyles
111
+ dependencies={storybookDependenciesV2}
112
+ />
113
+ );
107
114
  };
@@ -1,4 +1,4 @@
1
- import {KeypadContext} from "@khanacademy/math-input";
1
+ import {KeypadContext, KeypadType} from "@khanacademy/math-input";
2
2
  import * as React from "react";
3
3
 
4
4
  import {ItemRendererWithDebugUI} from "../../../../../testing/item-renderer-with-debug-ui";
@@ -62,7 +62,7 @@ export const DesktopKitchenSink = (args: StoryArgs): React.ReactElement => {
62
62
  };
63
63
 
64
64
  const keypadConfiguration = {
65
- keypadType: "EXPRESSION",
65
+ keypadType: KeypadType.EXPRESSION,
66
66
  extraKeys: ["x", "y", "z"],
67
67
  };
68
68
 
@@ -3,7 +3,10 @@ import {screen, fireEvent} from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import "@testing-library/jest-dom";
5
5
 
6
- import {testDependencies} from "../../../../../testing/test-dependencies";
6
+ import {
7
+ testDependencies,
8
+ testDependenciesV2,
9
+ } from "../../../../../testing/test-dependencies";
7
10
  import * as Dependencies from "../../dependencies";
8
11
  import {
9
12
  expressionItem2,
@@ -15,8 +18,13 @@ import {Expression} from "../expression";
15
18
  import {renderQuestion} from "./renderQuestion";
16
19
 
17
20
  import type {PerseusItem} from "../../perseus-types";
21
+ import type {APIOptions} from "../../types";
18
22
 
19
- const assertComplete = (itemData: PerseusItem, input, isCorrect: boolean) => {
23
+ const assertComplete = (
24
+ itemData: PerseusItem,
25
+ input: string,
26
+ isCorrect: boolean,
27
+ ) => {
20
28
  const {renderer} = renderQuestion(itemData.question);
21
29
  userEvent.type(screen.getByRole("textbox"), input);
22
30
  const [_, score] = renderer.guessAndScore();
@@ -27,13 +35,14 @@ const assertComplete = (itemData: PerseusItem, input, isCorrect: boolean) => {
27
35
  });
28
36
  };
29
37
 
30
- const assertCorrect = (itemData: PerseusItem, input) => {
38
+ const assertCorrect = (itemData: PerseusItem, input: string) => {
31
39
  assertComplete(itemData, input, true);
32
40
 
33
- expect(testDependencies.analytics).toHaveBeenCalledWith({
41
+ expect(testDependenciesV2.analytics.onAnalyticsEvent).toHaveBeenCalledWith({
34
42
  type: "perseus:expression-evaluated",
35
43
  payload: {
36
44
  result: "correct",
45
+ virtualKeypadVersion: "PERSEUS_MATH_INPUT",
37
46
  },
38
47
  });
39
48
  };
@@ -41,16 +50,21 @@ const assertCorrect = (itemData: PerseusItem, input) => {
41
50
  const assertIncorrect = (itemData: PerseusItem, input: string) => {
42
51
  assertComplete(itemData, input, false);
43
52
 
44
- expect(testDependencies.analytics).toHaveBeenCalledWith({
53
+ expect(testDependenciesV2.analytics.onAnalyticsEvent).toHaveBeenCalledWith({
45
54
  type: "perseus:expression-evaluated",
46
55
  payload: {
47
56
  result: "incorrect",
57
+ virtualKeypadVersion: "PERSEUS_MATH_INPUT",
48
58
  },
49
59
  });
50
60
  };
51
61
 
52
62
  // TODO: actually Assert that message is being set on the score object.
53
- const assertInvalid = (itemData: PerseusItem, input, message?: string) => {
63
+ const assertInvalid = (
64
+ itemData: PerseusItem,
65
+ input: string,
66
+ message?: string,
67
+ ) => {
54
68
  const {renderer} = renderQuestion(itemData.question);
55
69
  if (input.length) {
56
70
  userEvent.type(screen.getByRole("textbox"), input);
@@ -58,17 +72,18 @@ const assertInvalid = (itemData: PerseusItem, input, message?: string) => {
58
72
  const [_, score] = renderer.guessAndScore();
59
73
  expect(score).toMatchObject({type: "invalid"});
60
74
 
61
- expect(testDependencies.analytics).toHaveBeenCalledWith({
75
+ expect(testDependenciesV2.analytics.onAnalyticsEvent).toHaveBeenCalledWith({
62
76
  type: "perseus:expression-evaluated",
63
77
  payload: {
64
78
  result: "invalid",
79
+ virtualKeypadVersion: "PERSEUS_MATH_INPUT",
65
80
  },
66
81
  });
67
82
  };
68
83
 
69
84
  describe("Expression Widget", function () {
70
85
  beforeEach(() => {
71
- jest.spyOn(testDependencies, "analytics");
86
+ jest.spyOn(testDependenciesV2.analytics, "onAnalyticsEvent");
72
87
  jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
73
88
  testDependencies,
74
89
  );
@@ -171,6 +186,56 @@ describe("Expression Widget", function () {
171
186
  expect(err).toStrictEqual({message: null, type: "invalid"});
172
187
  });
173
188
  });
189
+
190
+ describe("analytics", () => {
191
+ const assertKeypadVersion = (
192
+ apiOptions: APIOptions,
193
+ virtualKeypadVersion: string,
194
+ ) => {
195
+ const {renderer} = renderQuestion(
196
+ expressionItem2.question,
197
+ apiOptions,
198
+ );
199
+
200
+ renderer.guessAndScore();
201
+
202
+ expect(
203
+ testDependenciesV2.analytics.onAnalyticsEvent,
204
+ ).toHaveBeenCalledWith({
205
+ type: "perseus:expression-evaluated",
206
+ payload: {
207
+ // We're not interested in validating that the expression
208
+ // widget did anything useful or that the keypad worked. We
209
+ // just want to make sure the code that derives which
210
+ // keypad version it detected is correct.
211
+ result: "invalid",
212
+ virtualKeypadVersion,
213
+ },
214
+ });
215
+ };
216
+
217
+ it("should set the virtual keypad version to REACT_NATIVE_KEYPAD when nativeKeypadProxy is provided", () => {
218
+ assertKeypadVersion(
219
+ {nativeKeypadProxy: jest.fn()},
220
+ "REACT_NATIVE_KEYPAD",
221
+ );
222
+ });
223
+
224
+ it("should set the virtual keypad version to MATH_INPUT_KEYPAD_V1 when customKeypad is set and useV2Keypad is unset", () => {
225
+ assertKeypadVersion({customKeypad: true}, "MATH_INPUT_KEYPAD_V1");
226
+ });
227
+
228
+ it("should set the virtual keypad version to MATH_INPUT_KEYPAD_V2 when customKeypad is set and useV2Keypad is true", () => {
229
+ assertKeypadVersion(
230
+ {customKeypad: true, useV2Keypad: true},
231
+ "MATH_INPUT_KEYPAD_V2",
232
+ );
233
+ });
234
+
235
+ it("should default the virtual keypad version to PERSEUS_MATH_INPUT", () => {
236
+ assertKeypadVersion(Object.freeze({}), "PERSEUS_MATH_INPUT");
237
+ });
238
+ });
174
239
  });
175
240
 
176
241
  describe("getOneCorrectAnswerFromRubric", () => {
@@ -2,6 +2,9 @@ import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
2
2
  import {render} from "@testing-library/react";
3
3
  import * as React from "react";
4
4
 
5
+ // eslint-disable-next-line import/no-relative-packages
6
+ import {testDependenciesV2} from "../../../../../testing/test-dependencies";
7
+ import {DependenciesContext} from "../../dependencies";
5
8
  import * as Perseus from "../../index";
6
9
  import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing";
7
10
 
@@ -32,18 +35,21 @@ export const renderQuestion = (
32
35
  unmount: RenderResult["unmount"];
33
36
  } => {
34
37
  registerAllWidgetsForTesting();
38
+
35
39
  let renderer: Perseus.Renderer | null = null;
36
40
  const {container, rerender, unmount} = render(
37
41
  <RenderStateRoot>
38
- <Perseus.Renderer
39
- ref={(node) => (renderer = node)}
40
- content={question.content}
41
- images={question.images}
42
- widgets={question.widgets}
43
- problemNum={0}
44
- apiOptions={apiOptions}
45
- {...extraProps}
46
- />
42
+ <DependenciesContext.Provider value={testDependenciesV2}>
43
+ <Perseus.Renderer
44
+ ref={(node) => (renderer = node)}
45
+ content={question.content}
46
+ images={question.images}
47
+ widgets={question.widgets}
48
+ problemNum={0}
49
+ apiOptions={apiOptions}
50
+ {...extraProps}
51
+ />
52
+ </DependenciesContext.Provider>
47
53
  </RenderStateRoot>,
48
54
  );
49
55
  if (!renderer) {
@@ -56,15 +62,17 @@ export const renderQuestion = (
56
62
  ) => {
57
63
  rerender(
58
64
  <RenderStateRoot>
59
- <Perseus.Renderer
60
- ref={(node) => (renderer = node)}
61
- content={question.content}
62
- images={question.images}
63
- widgets={question.widgets}
64
- problemNum={0}
65
- apiOptions={apiOptions}
66
- {...extraProps}
67
- />
65
+ <DependenciesContext.Provider value={testDependenciesV2}>
66
+ <Perseus.Renderer
67
+ ref={(node) => (renderer = node)}
68
+ content={question.content}
69
+ images={question.images}
70
+ widgets={question.widgets}
71
+ problemNum={0}
72
+ apiOptions={apiOptions}
73
+ {...extraProps}
74
+ />
75
+ </DependenciesContext.Provider>
68
76
  </RenderStateRoot>,
69
77
  );
70
78
  if (!renderer) {
@@ -12,7 +12,7 @@ import Tooltip, {
12
12
  HorizontalDirection,
13
13
  VerticalDirection,
14
14
  } from "../components/tooltip";
15
- import {getDependencies} from "../dependencies";
15
+ import {useDependencies} from "../dependencies";
16
16
  import {iconExclamationSign} from "../icon-paths";
17
17
  import {Errors as PerseusErrors, Log} from "../logging/log";
18
18
  import * as Changeable from "../mixins/changeable";
@@ -23,20 +23,14 @@ import type {
23
23
  PerseusExpressionWidgetOptions,
24
24
  PerseusExpressionAnswerForm,
25
25
  } from "../perseus-types";
26
- import type {PerseusScore, WidgetExports, WidgetProps} from "../types";
26
+ import type {
27
+ APIOptions,
28
+ PerseusScore,
29
+ WidgetExports,
30
+ WidgetProps,
31
+ } from "../types";
27
32
  import type {Keys as Key} from "@khanacademy/math-input";
28
33
 
29
- const sendExpressionEvaluatedEvent = (
30
- result: "correct" | "incorrect" | "invalid",
31
- ) => {
32
- getDependencies().analytics({
33
- type: "perseus:expression-evaluated",
34
- payload: {
35
- result,
36
- },
37
- });
38
- };
39
-
40
34
  type InputPath = ReadonlyArray<string>;
41
35
 
42
36
  const ERROR_MESSAGE = i18n._("Sorry, I don't understand that!");
@@ -63,20 +57,43 @@ const insertBraces = (value) => {
63
57
  return value.replace(/([_^])([^{])/g, "$1{$2}");
64
58
  };
65
59
 
60
+ const deriveKeypadVersion = (apiOptions: APIOptions) => {
61
+ // We can derive which version of the keypad is in use. This is
62
+ // a bit tricky, but this code will be relatively short-lived
63
+ // as we coalesce onto the new, v2 Keypad, at which point we
64
+ // can remove this `virtualKeypadVersion` field entirely.
65
+ return apiOptions.nativeKeypadProxy != null
66
+ ? "REACT_NATIVE_KEYPAD"
67
+ : apiOptions.customKeypad === true
68
+ ? apiOptions.useV2Keypad === true
69
+ ? "MATH_INPUT_KEYPAD_V2"
70
+ : "MATH_INPUT_KEYPAD_V1"
71
+ : "PERSEUS_MATH_INPUT";
72
+ };
73
+
66
74
  type Rubric = PerseusExpressionWidgetOptions;
67
75
 
76
+ type RenderProps = {
77
+ buttonSets: PerseusExpressionWidgetOptions["buttonSets"];
78
+ buttonsVisible?: PerseusExpressionWidgetOptions["buttonsVisible"];
79
+ functions: PerseusExpressionWidgetOptions["functions"];
80
+ times: PerseusExpressionWidgetOptions["times"];
81
+ keypadConfiguration: ReturnType<typeof keypadConfigurationForProps>;
82
+ };
83
+
68
84
  type ExternalProps = WidgetProps<RenderProps, Rubric>;
69
85
 
70
- export type Props = ExternalProps & {
71
- apiOptions: NonNullable<ExternalProps["apiOptions"]>;
72
- buttonSets: NonNullable<ExternalProps["buttonSets"]>;
73
- functions: NonNullable<ExternalProps["functions"]>;
74
- linterContext: NonNullable<ExternalProps["linterContext"]>;
75
- onBlur: NonNullable<ExternalProps["onBlur"]>;
76
- onFocus: NonNullable<ExternalProps["onFocus"]>;
77
- times: NonNullable<ExternalProps["times"]>;
78
- value: string;
79
- };
86
+ export type Props = ExternalProps &
87
+ ReturnType<typeof useDependencies> & {
88
+ apiOptions: NonNullable<ExternalProps["apiOptions"]>;
89
+ buttonSets: NonNullable<ExternalProps["buttonSets"]>;
90
+ functions: NonNullable<ExternalProps["functions"]>;
91
+ linterContext: NonNullable<ExternalProps["linterContext"]>;
92
+ onBlur: NonNullable<ExternalProps["onBlur"]>;
93
+ onFocus: NonNullable<ExternalProps["onFocus"]>;
94
+ times: NonNullable<ExternalProps["times"]>;
95
+ value: string;
96
+ };
80
97
 
81
98
  export type ExpressionState = {
82
99
  showErrorTooltip: boolean;
@@ -211,8 +228,6 @@ export class Expression extends React.Component<Props, ExpressionState> {
211
228
  // we did, whether it's considered correct, incorrect, or ungraded
212
229
  if (!matchingAnswerForm) {
213
230
  if (firstUngradedResult) {
214
- sendExpressionEvaluatedEvent("invalid");
215
-
216
231
  // While we didn't directly match with any answer form, we
217
232
  // did at some point get an "ungraded" validation result,
218
233
  // which might indicate e.g. a mismatch in variable casing.
@@ -227,8 +242,6 @@ export class Expression extends React.Component<Props, ExpressionState> {
227
242
  };
228
243
  }
229
244
  if (allEmpty) {
230
- sendExpressionEvaluatedEvent("invalid");
231
-
232
245
  // If everything graded as empty, it's invalid.
233
246
  return {
234
247
  type: "invalid",
@@ -237,7 +250,6 @@ export class Expression extends React.Component<Props, ExpressionState> {
237
250
  }
238
251
  // We fell through all the possibilities and we're not empty,
239
252
  // so the answer is considered incorrect.
240
- sendExpressionEvaluatedEvent("incorrect");
241
253
  return {
242
254
  type: "points",
243
255
  earned: 0,
@@ -252,7 +264,6 @@ export class Expression extends React.Component<Props, ExpressionState> {
252
264
  userInput,
253
265
  matchMessage,
254
266
  );
255
- sendExpressionEvaluatedEvent("invalid");
256
267
  return {
257
268
  type: "invalid",
258
269
  message: apiResult === false ? null : matchMessage,
@@ -264,12 +275,6 @@ export class Expression extends React.Component<Props, ExpressionState> {
264
275
  // TODO(eater): Seems silly to translate result to this
265
276
  // invalid/points thing and immediately translate it back in
266
277
  // ItemRenderer.scoreInput()
267
- sendExpressionEvaluatedEvent(
268
- matchingAnswerForm.considered === "correct"
269
- ? "correct"
270
- : "incorrect",
271
- );
272
-
273
278
  return {
274
279
  type: "points",
275
280
  earned: matchingAnswerForm.considered === "correct" ? 1 : 0,
@@ -369,7 +374,21 @@ export class Expression extends React.Component<Props, ExpressionState> {
369
374
  onInputError: OnInputErrorFunctionType,
370
375
  ) => PerseusScore = (rubric, onInputError) => {
371
376
  onInputError = onInputError || function () {};
372
- return Expression.validate(this.getUserInput(), rubric, onInputError);
377
+ const result = Expression.validate(
378
+ this.getUserInput(),
379
+ rubric,
380
+ onInputError,
381
+ );
382
+
383
+ this.sendExpressionEvaluatedEvent(
384
+ result.type === "invalid"
385
+ ? "invalid"
386
+ : result.earned === result.total
387
+ ? "correct"
388
+ : "incorrect",
389
+ );
390
+
391
+ return result;
373
392
  };
374
393
 
375
394
  getUserInput: () => string = () => {
@@ -471,6 +490,18 @@ export class Expression extends React.Component<Props, ExpressionState> {
471
490
  );
472
491
  };
473
492
 
493
+ sendExpressionEvaluatedEvent(result: "correct" | "incorrect" | "invalid") {
494
+ this.props.analytics.onAnalyticsEvent({
495
+ type: "perseus:expression-evaluated",
496
+ payload: {
497
+ result,
498
+ virtualKeypadVersion: deriveKeypadVersion(
499
+ this.props.apiOptions,
500
+ ),
501
+ },
502
+ });
503
+ }
504
+
474
505
  render():
475
506
  | React.ReactNode
476
507
  | React.ReactElement<React.ComponentProps<"div">> {
@@ -640,22 +671,25 @@ const propUpgrades = {
640
671
  }),
641
672
  } as const;
642
673
 
643
- type RenderProps = {
644
- buttonSets: any;
645
- buttonsVisible?: "always" | "focused" | "never";
646
- functions: ReadonlyArray<string>;
647
- keypadConfiguration: {
648
- extraKeys: ReadonlyArray<any | string>;
649
- keypadType: any;
650
- };
651
- times: boolean;
652
- };
674
+ const ExpressionWithDependencies = React.forwardRef<
675
+ Expression,
676
+ Omit<
677
+ JSX.LibraryManagedAttributes<
678
+ typeof Expression,
679
+ React.ComponentProps<typeof Expression>
680
+ >,
681
+ keyof ReturnType<typeof useDependencies>
682
+ >
683
+ >((props, ref) => {
684
+ const deps = useDependencies();
685
+ return <Expression ref={ref} analytics={deps.analytics} {...props} />;
686
+ });
653
687
 
654
688
  export default {
655
689
  name: "expression",
656
690
  displayName: "Expression / Equation",
657
691
  defaultAlignment: "inline-block",
658
- widget: Expression,
692
+ widget: ExpressionWithDependencies,
659
693
  transform: (widgetOptions: PerseusExpressionWidgetOptions): RenderProps => {
660
694
  const {times, functions, buttonSets, buttonsVisible} = widgetOptions;
661
695
  return {
@@ -671,4 +705,4 @@ export default {
671
705
 
672
706
  // For use by the editor
673
707
  isLintable: true,
674
- } as WidgetExports<typeof Expression>;
708
+ } as WidgetExports<typeof ExpressionWithDependencies>;