diff --git a/.changeset/twelve-hornets-sing.md b/.changeset/twelve-hornets-sing.md new file mode 100644 index 00000000000..01a73bdc3d4 --- /dev/null +++ b/.changeset/twelve-hornets-sing.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Adding rendering events to grapher, graded-group, graded-group-set, matrix, orderer, passage, and plotter diff --git a/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts b/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts index 028d710caaa..c8cada8ccad 100644 --- a/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts +++ b/packages/perseus/src/widgets/graded-group-set/graded-group-set.test.ts @@ -1,7 +1,10 @@ import {act, screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; import {renderQuestion} from "../__testutils__/renderQuestion"; @@ -10,6 +13,7 @@ import { groupSetRadioRationaleQuestion, } from "./graded-group-set.testdata"; +import type {PerseusDependenciesV2} from "../../types"; import type {PerseusRenderer} from "@khanacademy/perseus-core"; import type {UserEvent} from "@testing-library/user-event"; @@ -41,6 +45,28 @@ describe("graded group set widget", () => { expect(container).toMatchSnapshot(); }); + it("should send analytics event when widget is rendered", () => { + // Arrange + const onAnalyticsEventSpy = jest.fn(); + const depsV2: PerseusDependenciesV2 = { + ...testDependenciesV2, + analytics: {onAnalyticsEvent: onAnalyticsEventSpy}, + }; + + // Act + renderQuestion(article1, undefined, undefined, undefined, depsV2); + + // Assert + expect(onAnalyticsEventSpy).toHaveBeenCalledWith({ + type: "perseus:widget:rendered:ti", + payload: { + widgetSubType: "null", + widgetType: "graded-group-set", + widgetId: "graded-group-set 1", + }, + }); + }); + it("should render error message when no current group", () => { // Arrange const articleWithNoGradedGroups: PerseusRenderer = { diff --git a/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx b/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx index 8e776c247f6..0d4496c9e6c 100644 --- a/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx +++ b/packages/perseus/src/widgets/graded-group-set/graded-group-set.tsx @@ -8,6 +8,7 @@ import classNames from "classnames"; import * as React from "react"; import {PerseusI18nContext} from "../../components/i18n-context"; +import {withDependencies} from "../../components/with-dependencies"; import {getDependencies} from "../../dependencies"; import { gray76, @@ -19,7 +20,13 @@ import a11y from "../../util/a11y"; import {getPromptJSON} from "../../widget-ai-utils/graded-group-set/graded-group-set-ai-utils"; import {GradedGroup} from "../graded-group/graded-group"; -import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; +import type { + FocusPath, + PerseusDependenciesV2, + Widget, + WidgetExports, + WidgetProps, +} from "../../types"; import type {GradedGroupSetPromptJSON} from "../../widget-ai-utils/graded-group-set/graded-group-set-ai-utils"; import type { PerseusGradedGroupSetWidgetOptions, @@ -90,6 +97,7 @@ class Indicators extends React.Component { type Props = WidgetProps & { trackInteraction: () => void; + dependencies: PerseusDependenciesV2; }; type DefaultProps = { @@ -116,6 +124,17 @@ class GradedGroupSet extends React.Component implements Widget { currentGroup: 0, }; + componentDidMount(): void { + this.props.dependencies.analytics.onAnalyticsEvent({ + type: "perseus:widget:rendered:ti", + payload: { + widgetType: "graded-group-set", + widgetSubType: "null", + widgetId: this.props.widgetId, + }, + }); + } + shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return nextProps !== this.props || nextState !== this.state; } @@ -228,15 +247,17 @@ class GradedGroupSet extends React.Component implements Widget { } } +const WrappedGradedGroupSet = withDependencies(GradedGroupSet); + export default { name: "graded-group-set", displayName: "Graded group set (articles only)", - widget: GradedGroupSet, + widget: WrappedGradedGroupSet, // TODO(michaelpolyak): This widget should be available for articles only hidden: false, tracking: "all", isLintable: true, -} satisfies WidgetExports; +} satisfies WidgetExports; const styles = StyleSheet.create({ top: { diff --git a/packages/perseus/src/widgets/graded-group/graded-group.test.ts b/packages/perseus/src/widgets/graded-group/graded-group.test.ts index 1d4ef9c8884..adc569b6372 100644 --- a/packages/perseus/src/widgets/graded-group/graded-group.test.ts +++ b/packages/perseus/src/widgets/graded-group/graded-group.test.ts @@ -2,7 +2,10 @@ import {describe, beforeEach, it} from "@jest/globals"; import {act, screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import {renderArticle} from "../../__tests__/article-renderer.test"; import * as Dependencies from "../../dependencies"; import {renderQuestion} from "../__testutils__/renderQuestion"; @@ -12,7 +15,7 @@ import { groupedRadioRationaleQuestion, } from "./graded-group.testdata"; -import type {APIOptions} from "../../types"; +import type {APIOptions, PerseusDependenciesV2} from "../../types"; import type {PerseusArticle} from "@khanacademy/perseus-core"; import type {UserEvent} from "@testing-library/user-event"; @@ -56,6 +59,28 @@ describe("graded-group", () => { ); }); + it("should send analytics event when widget is rendered", () => { + // Arrange + const onAnalyticsEventSpy = jest.fn(); + const depsV2: PerseusDependenciesV2 = { + ...testDependenciesV2, + analytics: {onAnalyticsEvent: onAnalyticsEventSpy}, + }; + + // Act + renderQuestion(question1, undefined, undefined, undefined, depsV2); + + // Assert + expect(onAnalyticsEventSpy).toHaveBeenCalledWith({ + type: "perseus:widget:rendered:ti", + payload: { + widgetSubType: "null", + widgetType: "graded-group", + widgetId: "graded-group 1", + }, + }); + }); + describe("on desktop", () => { it("should be able to be answered correctly", async () => { // Arrange diff --git a/packages/perseus/src/widgets/graded-group/graded-group.tsx b/packages/perseus/src/widgets/graded-group/graded-group.tsx index ffa1f8a39e4..e18a9575595 100644 --- a/packages/perseus/src/widgets/graded-group/graded-group.tsx +++ b/packages/perseus/src/widgets/graded-group/graded-group.tsx @@ -9,6 +9,7 @@ import _ from "underscore"; import {PerseusI18nContext} from "../../components/i18n-context"; import InlineIcon from "../../components/inline-icon"; +import {withDependencies} from "../../components/with-dependencies"; import {iconOk, iconRemove} from "../../icon-paths"; import {ApiOptions} from "../../perseus-api"; import Renderer from "../../renderer"; @@ -29,6 +30,7 @@ import GradedGroupAnswerBar from "./graded-group-answer-bar"; import type {ANSWER_BAR_STATES} from "./graded-group-answer-bar"; import type { FocusPath, + PerseusDependenciesV2, TrackingGradedGroupExtraArguments, Widget, WidgetExports, @@ -75,6 +77,7 @@ type Props = WidgetProps< > & { inGradedGroupSet?: boolean; // Set by graded-group-set.jsx, onNextQuestion?: () => unknown; // Set by graded-group-set.jsx + dependencies: PerseusDependenciesV2; }; type DefaultProps = { @@ -103,7 +106,7 @@ type State = { 0 as any as WidgetProps< PerseusGradedGroupWidgetOptions, Empty -> satisfies PropsFor; +> satisfies PropsFor; // A Graded Group is more or less a Group widget that displays a check // answer button below the rendered content. When clicked, the widget grades @@ -136,6 +139,17 @@ export class GradedGroup rendererRef = React.createRef(); hintRendererRef = React.createRef(); + componentDidMount(): void { + this.props.dependencies.analytics.onAnalyticsEvent({ + type: "perseus:widget:rendered:ti", + payload: { + widgetType: "graded-group", + widgetSubType: "null", + widgetId: this.props.widgetId, + }, + }); + } + shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return nextProps !== this.props || nextState !== this.state; } @@ -496,12 +510,14 @@ const styles = StyleSheet.create({ }, }); +const WrappedGradedGroup = withDependencies(GradedGroup); + export default { name: "graded-group", displayName: "Graded group (articles only)", - widget: GradedGroup, + widget: WrappedGradedGroup, // TODO(aasmund): This widget should be available for articles only hidden: false, tracking: "all", isLintable: true, -} satisfies WidgetExports; +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/grapher/grapher.test.ts b/packages/perseus/src/widgets/grapher/grapher.test.ts index 8aca5ed4c42..261c6c8229e 100644 --- a/packages/perseus/src/widgets/grapher/grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/grapher.test.ts @@ -1,4 +1,7 @@ -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import {waitForInitialGraphieRender} from "../../../../../testing/wait"; import * as Dependencies from "../../dependencies"; import {renderQuestion} from "../__testutils__/renderQuestion"; @@ -8,6 +11,8 @@ import { multipleAvailableTypesQuestion, } from "./grapher.testdata"; +import type {PerseusDependenciesV2} from "../../types"; + describe("grapher widget", () => { beforeEach(() => { jest.spyOn(Dependencies, "getDependencies").mockReturnValue( @@ -40,4 +45,25 @@ describe("grapher widget", () => { // Assert expect(container).toMatchSnapshot("initial render"); }); + + it("should send analytics event when widget is rendered", () => { + // Arrange + const onAnalyticsEventSpy = jest.fn(); + const depsV2: PerseusDependenciesV2 = { + ...testDependenciesV2, + analytics: {onAnalyticsEvent: onAnalyticsEventSpy}, + }; + + // Act + renderQuestion(linearQuestion, undefined, undefined, undefined, depsV2); + // Assert + expect(onAnalyticsEventSpy).toHaveBeenCalledWith({ + type: "perseus:widget:rendered:ti", + payload: { + widgetSubType: "null", + widgetType: "grapher", + widgetId: "grapher 1", + }, + }); + }); }); diff --git a/packages/perseus/src/widgets/grapher/grapher.tsx b/packages/perseus/src/widgets/grapher/grapher.tsx index 5223ad95af8..61b2f7800d1 100644 --- a/packages/perseus/src/widgets/grapher/grapher.tsx +++ b/packages/perseus/src/widgets/grapher/grapher.tsx @@ -9,6 +9,7 @@ import * as React from "react"; import ButtonGroup from "../../components/button-group"; import Graphie from "../../components/graphie"; import SvgImage from "../../components/svg-image"; +import {withDependencies} from "../../components/with-dependencies"; import Interactive2 from "../../interactive2"; import WrappedLine from "../../interactive2/wrapped-line"; import {interactiveSizes} from "../../styles/constants"; @@ -30,7 +31,12 @@ import { } from "./util"; import type {Coord, Line} from "../../interactive2/types"; -import type {Widget, WidgetExports, WidgetProps} from "../../types"; +import type { + PerseusDependenciesV2, + Widget, + WidgetExports, + WidgetProps, +} from "../../types"; import type {GridDimensions} from "../../util"; import type {GrapherPromptJSON} from "../../widget-ai-utils/grapher/grapher-ai-utils"; import type { @@ -341,13 +347,26 @@ class FunctionGrapher extends React.Component { } } -type Props = WidgetProps; - +type Props = WidgetProps< + PerseusGrapherWidgetOptions, + PerseusGrapherUserInput +> & {dependencies: PerseusDependenciesV2}; /* Widget and editor. */ class Grapher extends React.Component implements Widget { horizHairline: any; vertHairline: any; + componentDidMount(): void { + this.props.dependencies.analytics.onAnalyticsEvent({ + type: "perseus:widget:rendered:ti", + payload: { + widgetType: "grapher", + widgetSubType: "null", + widgetId: this.props.widgetId, + }, + }); + } + handlePlotChanges: (arg1: any) => any = (newPlot) => { const plot = {...this.props.userInput, ...newPlot}; this.props.handleUserInput(plot); @@ -631,14 +650,16 @@ function getCorrectUserInput( 0 as any as WidgetProps< PerseusGrapherWidgetOptions, PerseusGrapherUserInput -> satisfies PropsFor; +> satisfies PropsFor; + +const WrappedGrapher = withDependencies(Grapher); export default { name: "grapher", displayName: "Grapher", hidden: true, - widget: Grapher, + widget: WrappedGrapher, getUserInputFromSerializedState, getStartUserInput, getCorrectUserInput, -} satisfies WidgetExports; +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/grapher/serialize-grapher.test.ts b/packages/perseus/src/widgets/grapher/serialize-grapher.test.ts index a496d2299ca..64814cecca6 100644 --- a/packages/perseus/src/widgets/grapher/serialize-grapher.test.ts +++ b/packages/perseus/src/widgets/grapher/serialize-grapher.test.ts @@ -4,7 +4,10 @@ import { } from "@khanacademy/perseus-core"; import {act} from "@testing-library/react"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import {renderQuestion} from "../../__tests__/test-utils"; import * as Dependencies from "../../dependencies"; import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing"; @@ -98,6 +101,7 @@ describe("Grapher serialization", () => { alignment: "default", static: false, availableTypes: ["linear"], + dependencies: testDependenciesV2, graph: { range: [ [-10, 10], diff --git a/packages/perseus/src/widgets/matrix/matrix.test.ts b/packages/perseus/src/widgets/matrix/matrix.test.ts index bf3fa354f23..668254f5d80 100644 --- a/packages/perseus/src/widgets/matrix/matrix.test.ts +++ b/packages/perseus/src/widgets/matrix/matrix.test.ts @@ -3,7 +3,10 @@ import {scorePerseusItem, validateMatrix} from "@khanacademy/perseus-score"; import {screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing"; import { @@ -16,7 +19,7 @@ import {renderQuestion} from "../__testutils__/renderQuestion"; import matrixExport from "./matrix"; import {question1} from "./matrix.testdata"; -import type {APIOptions} from "../../types"; +import type {APIOptions, PerseusDependenciesV2} from "../../types"; import type {UserEvent} from "@testing-library/user-event"; describe("matrix widget", () => { @@ -57,6 +60,27 @@ describe("matrix widget", () => { expect(container).toMatchSnapshot("first mobile render"); }); + it("should send analytics event when widget is rendered", () => { + // Arrange + const onAnalyticsEventSpy = jest.fn(); + const depsV2: PerseusDependenciesV2 = { + ...testDependenciesV2, + analytics: {onAnalyticsEvent: onAnalyticsEventSpy}, + }; + + // Act + renderQuestion(question1, undefined, undefined, undefined, depsV2); + // Assert + expect(onAnalyticsEventSpy).toHaveBeenCalledWith({ + type: "perseus:widget:rendered:ti", + payload: { + widgetSubType: "null", + widgetType: "matrix", + widgetId: "matrix 1", + }, + }); + }); + // Regression (LEMS-3307) it("can validate initial user input", () => { const initialUserInput = matrixExport.getStartUserInput(); diff --git a/packages/perseus/src/widgets/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index 46daf97f66f..08737428581 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -9,12 +9,19 @@ import _ from "underscore"; import {PerseusI18nContext} from "../../components/i18n-context"; import SimpleKeypadInput from "../../components/simple-keypad-input"; import TextInput from "../../components/text-input"; +import {withDependencies} from "../../components/with-dependencies"; import InteractiveUtil from "../../interactive2/interactive-util"; import {ApiOptions} from "../../perseus-api"; import Renderer from "../../renderer"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; -import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; +import type { + FocusPath, + PerseusDependenciesV2, + Widget, + WidgetExports, + WidgetProps, +} from "../../types"; import type {MatrixPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; import type { MatrixPublicWidgetOptions, @@ -81,9 +88,11 @@ const getRefForPath = function (path: FocusPath) { 0 as any as WidgetProps< PerseusMatrixWidgetOptions, PerseusMatrixUserInput -> satisfies PropsFor; +> satisfies PropsFor; -type Props = WidgetProps; +type Props = WidgetProps & { + dependencies: PerseusDependenciesV2; +}; type DefaultProps = { matrixBoardSize: Props["matrixBoardSize"]; @@ -124,6 +133,14 @@ class Matrix extends React.Component implements Widget { }; componentDidMount() { + this.props.dependencies.analytics.onAnalyticsEvent({ + type: "perseus:widget:rendered:ti", + payload: { + widgetType: "matrix", + widgetSubType: "null", + widgetId: this.props.widgetId, + }, + }); // Used in the `onBlur` and `onFocus` handlers this.cursorPosition = [0, 0]; } @@ -513,13 +530,15 @@ function getUserInputFromSerializedState( return {answers: serializedState.answers}; } +const WrappedMatrix = withDependencies(Matrix); + export default { name: "matrix", displayName: "Matrix", hidden: true, - widget: Matrix, + widget: WrappedMatrix, isLintable: true, getStartUserInput, getCorrectUserInput, getUserInputFromSerializedState, -} satisfies WidgetExports; +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/matrix/serialize-matrix.test.ts b/packages/perseus/src/widgets/matrix/serialize-matrix.test.ts index cbd186d7fc1..c57fcbca37e 100644 --- a/packages/perseus/src/widgets/matrix/serialize-matrix.test.ts +++ b/packages/perseus/src/widgets/matrix/serialize-matrix.test.ts @@ -5,7 +5,10 @@ import { import {screen, act} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import {renderQuestion} from "../../__tests__/test-utils"; import * as Dependencies from "../../dependencies"; import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing"; @@ -95,6 +98,7 @@ describe("Matrix serialization", () => { prefix: "", suffix: "", cursorPosition: [1, 1], + dependencies: testDependenciesV2, answers: [ ["1", "2"], ["3", "4"], diff --git a/packages/perseus/src/widgets/orderer/orderer.test.ts b/packages/perseus/src/widgets/orderer/orderer.test.ts index f41b03600d1..0a770d4fae8 100644 --- a/packages/perseus/src/widgets/orderer/orderer.test.ts +++ b/packages/perseus/src/widgets/orderer/orderer.test.ts @@ -1,14 +1,17 @@ import {scorePerseusItem} from "@khanacademy/perseus-score"; import {act, screen} from "@testing-library/react"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; import {getAnswerfulItem, getAnswerlessItem} from "../../util/test-utils"; import {renderQuestion} from "../__testutils__/renderQuestion"; import {question2} from "./orderer.testdata"; -import type {APIOptions} from "../../types"; +import type {APIOptions, PerseusDependenciesV2} from "../../types"; import type {PerseusOrdererWidgetOptions} from "@khanacademy/perseus-core"; const ordererOptions: PerseusOrdererWidgetOptions = { @@ -60,6 +63,27 @@ describe("orderer widget", () => { expect(container).toMatchSnapshot("first mobile render"); }); + it("should send analytics event when widget is rendered", () => { + // Arrange + const onAnalyticsEventSpy = jest.fn(); + const depsV2: PerseusDependenciesV2 = { + ...testDependenciesV2, + analytics: {onAnalyticsEvent: onAnalyticsEventSpy}, + }; + + // Act + renderQuestion(question2, undefined, undefined, undefined, depsV2); + // Assert + expect(onAnalyticsEventSpy).toHaveBeenCalledWith({ + type: "perseus:widget:rendered:ti", + payload: { + widgetSubType: "null", + widgetType: "orderer", + widgetId: "orderer 1", + }, + }); + }); + test("the answerless test data doesn't contain answers", () => { // Arrange / Act / Assert expect( diff --git a/packages/perseus/src/widgets/orderer/orderer.tsx b/packages/perseus/src/widgets/orderer/orderer.tsx index b1c09d35f94..ff45886845b 100644 --- a/packages/perseus/src/widgets/orderer/orderer.tsx +++ b/packages/perseus/src/widgets/orderer/orderer.tsx @@ -8,12 +8,18 @@ import ReactDOM from "react-dom"; import _ from "underscore"; import {PerseusI18nContext} from "../../components/i18n-context"; +import {withDependencies} from "../../components/with-dependencies"; import {Log} from "../../logging/log"; import Renderer from "../../renderer"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; -import type {Widget, WidgetExports, WidgetProps} from "../../types"; +import type { + PerseusDependenciesV2, + Widget, + WidgetExports, + WidgetProps, +} from "../../types"; import type {OrdererPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; import type { PerseusOrdererWidgetOptions, @@ -297,7 +303,7 @@ class Card extends React.Component { type OrdererProps = WidgetProps< OrdererPublicWidgetOptions, PerseusOrdererUserInput ->; +> & {dependencies: PerseusDependenciesV2}; type OrdererDefaultProps = Pick< OrdererProps, @@ -322,12 +328,12 @@ type OrdererState = { 0 as any as WidgetProps< PerseusOrdererWidgetOptions, PerseusOrdererUserInput -> satisfies PropsFor; +> satisfies PropsFor; 0 as any as WidgetProps< OrdererPublicWidgetOptions, PerseusOrdererUserInput -> satisfies PropsFor; +> satisfies PropsFor; class Orderer extends React.Component @@ -356,6 +362,17 @@ class Orderer grabPos: null, }; + componentDidMount(): void { + this.props.dependencies.analytics.onAnalyticsEvent({ + type: "perseus:widget:rendered:ti", + payload: { + widgetType: "orderer", + widgetSubType: "null", + widgetId: this.props.widgetId, + }, + }); + } + onClick: (arg1: string, arg2: number, arg3: any, arg4: Element) => void = ( type, index, @@ -758,12 +775,14 @@ function getStartUserInput(): PerseusOrdererUserInput { return {current: []}; } +const WrappedOrderer = withDependencies(Orderer); + export default { name: "orderer", displayName: "Orderer", hidden: true, - widget: Orderer, + widget: WrappedOrderer, isLintable: true, getStartUserInput, getUserInputFromSerializedState, -} satisfies WidgetExports; +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/orderer/serialize-orderer.test.ts b/packages/perseus/src/widgets/orderer/serialize-orderer.test.ts index 32ef0db639e..af49223efb5 100644 --- a/packages/perseus/src/widgets/orderer/serialize-orderer.test.ts +++ b/packages/perseus/src/widgets/orderer/serialize-orderer.test.ts @@ -4,7 +4,10 @@ import { } from "@khanacademy/perseus-core"; import {act} from "@testing-library/react"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import {renderQuestion} from "../../__tests__/test-utils"; import * as Dependencies from "../../dependencies"; import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing"; @@ -140,6 +143,7 @@ describe("Orderer serialization", () => { content: "3", }, ], + dependencies: testDependenciesV2, height: "normal", layout: "horizontal", }, diff --git a/packages/perseus/src/widgets/passage-ref/passage-ref.test.ts b/packages/perseus/src/widgets/passage-ref/passage-ref.test.ts index 3d9fe0ba66f..1e0874b293f 100644 --- a/packages/perseus/src/widgets/passage-ref/passage-ref.test.ts +++ b/packages/perseus/src/widgets/passage-ref/passage-ref.test.ts @@ -4,7 +4,7 @@ import {testDependencies} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; import {scorePerseusItemTesting} from "../../util/test-utils"; import {renderQuestion} from "../__testutils__/renderQuestion"; -import PassageExport from "../passage"; +import {Passage} from "../passage"; import {question1} from "./passage-ref.testdata"; @@ -15,10 +15,7 @@ const mockReference = ( content: string; }, ) => { - jest.spyOn( - PassageExport.widget.prototype, - "getReference", - ).mockImplementation(() => { + jest.spyOn(Passage.prototype, "getReference").mockImplementation(() => { return mock; }); }; diff --git a/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx b/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx index 2dc4ac521ed..1fd42a537d6 100644 --- a/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx +++ b/packages/perseus/src/widgets/passage/__tests__/passage.test.tsx @@ -1,63 +1,19 @@ import {it, describe, beforeEach} from "@jest/globals"; import {scorePerseusItem} from "@khanacademy/perseus-score"; -import {act, render, screen} from "@testing-library/react"; -import React from "react"; +import {act, screen} from "@testing-library/react"; -import {testDependencies} from "../../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../../testing/test-dependencies"; import * as Dependencies from "../../../dependencies"; -import {ApiOptions} from "../../../perseus-api"; import {renderQuestion} from "../../__testutils__/renderQuestion"; -import PassageWidgetExport, {LineHeightMeasurer} from "../passage"; - -import {question1, question2} from "./passage.testdata"; - -import type {APIOptions} from "../../../types"; - -function renderPassage( - overwrite: - | { - footnotes: string; - } - | { - passageText: string; - } - | { - passageTitle: string; - }, -) { - const widgetPropsBase = { - footnotes: "", - passageText: "", - passageTitle: "", - showLineNumbers: false, - static: true, - } as const; - - const base = { - ...widgetPropsBase, - alignment: null, - apiOptions: { - ...ApiOptions.defaults, - }, - containerSizeClass: "small", - findWidgets: (callback) => [], - onBlur: () => {}, - onChange: () => {}, - handleUserInput: () => {}, - userInput: {}, - onFocus: () => {}, - problemNum: 1, - static: true, - trackInteraction: () => {}, - widgetId: "passage", - widgetIndex: 0, - reviewMode: false, - } as const; - - const extended = {...base, ...overwrite} as const; - // TODO: use a Renderer wrapper rather than rendering this directly - return render(); -} +import {LineHeightMeasurer} from "../passage"; + +import {question1, question2, question3} from "./passage.testdata"; + +import type {APIOptions, PerseusDependenciesV2} from "../../../types"; +import type PassageWidgetExport from "../passage"; describe("passage widget", () => { beforeEach(() => { @@ -198,41 +154,71 @@ describe("passage widget", () => { }); it("should render passage title", () => { - renderPassage({passageTitle: "Passage title"}); - - expect(screen.getByText("Passage title")).toBeInTheDocument(); + renderQuestion( + question3, + undefined, + undefined, + undefined, + testDependenciesV2, + ); + expect(screen.getByText("Passage 1")).toBeInTheDocument(); }); it("should render passage text", () => { - renderPassage({passageText: "Passage text"}); + renderQuestion( + question1, + undefined, + undefined, + undefined, + testDependenciesV2, + ); - expect(screen.getByText("Passage text")).toBeInTheDocument(); + expect( + screen.getByText( + "Sociologists study folktales because they provide a means of understanding the distinctive values of a culture", + ), + ).toBeInTheDocument(); }); it("should render footnotes", () => { - renderPassage({footnotes: "Footnote text"}); - - expect(screen.getByText("Footnote text")).toBeInTheDocument(); + renderQuestion( + question3, + undefined, + undefined, + undefined, + testDependenciesV2, + ); + expect(screen.getByText("An example footnote")).toBeInTheDocument(); }); it("should render first question instructions", () => { - renderPassage({passageText: "[[test]] Passage text"}); - - expect(screen.getByText("The symbol")).toBeInTheDocument(); - expect(screen.getAllByText("[Marker for question test]")).toHaveLength( - 2, + renderQuestion( + question2, + undefined, + undefined, + undefined, + testDependenciesV2, ); + + expect(screen.getAllByText("The symbol")).toHaveLength(2); + expect(screen.getAllByText("[Marker for question 1]")).toHaveLength(2); expect( screen.getByText( - "indicates that question test references this portion of the passage", + "indicates that question 1 references this portion of the passage", ), ).toBeInTheDocument(); }); it("should render first sentence instructions", () => { - renderPassage({passageText: "[[1]] Passage text"}); + renderQuestion( + question2, + undefined, + undefined, + undefined, + testDependenciesV2, + ); - expect(screen.getByText("The symbol")).toBeInTheDocument(); + expect(screen.getAllByText("The symbol")).toHaveLength(2); expect(screen.getAllByText("[Marker for question 1]")).toHaveLength(2); expect( screen.getByText( @@ -240,4 +226,26 @@ describe("passage widget", () => { ), ).toBeInTheDocument(); }); + + it("should send analytics event when widget is rendered", () => { + // Arrange + const onAnalyticsEventSpy = jest.fn(); + const depsV2: PerseusDependenciesV2 = { + ...testDependenciesV2, + analytics: {onAnalyticsEvent: onAnalyticsEventSpy}, + }; + + // Act + renderQuestion(question2, undefined, undefined, undefined, depsV2); + + // Assert + expect(onAnalyticsEventSpy).toHaveBeenCalledWith({ + type: "perseus:widget:rendered:ti", + payload: { + widgetSubType: "null", + widgetType: "passage", + widgetId: "passage 1", + }, + }); + }); }); diff --git a/packages/perseus/src/widgets/passage/passage.tsx b/packages/perseus/src/widgets/passage/passage.tsx index a0d259ba1b3..4fca1de00dc 100644 --- a/packages/perseus/src/widgets/passage/passage.tsx +++ b/packages/perseus/src/widgets/passage/passage.tsx @@ -8,6 +8,7 @@ import _ from "underscore"; import HighlightableContent from "../../components/highlighting/highlightable-content"; import {PerseusI18nContext} from "../../components/i18n-context"; +import {withDependencies} from "../../components/with-dependencies"; import {getDependencies} from "../../dependencies"; import Renderer from "../../renderer"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/passage/passage-ai-utils"; @@ -17,7 +18,12 @@ import {isPassageWidget} from "./utils"; import type {ParseState} from "./passage-markdown"; import type {SerializedHighlightSet} from "../../components/highlighting/types"; -import type {WidgetExports, WidgetProps, Widget} from "../../types"; +import type { + WidgetExports, + WidgetProps, + Widget, + PerseusDependenciesV2, +} from "../../types"; import type {PassagePromptJSON} from "../../widget-ai-utils/passage/passage-ai-utils"; import type { PerseusPassageWidgetOptions, @@ -64,6 +70,7 @@ type FindWidgetsCallback = (id: string, widgetInfo: PerseusWidget) => boolean; type PassageProps = WidgetProps & { findWidgets: (arg1: FindWidgetsCallback) => ReadonlyArray; + dependencies: PerseusDependenciesV2; }; type DefaultPassageProps = { @@ -160,6 +167,15 @@ export class Passage this._stylesAppiedTimer = window.setTimeout(() => { this.setState({stylesAreApplied: true}); }, 0); + + this.props.dependencies.analytics.onAnalyticsEvent({ + type: "perseus:widget:rendered:ti", + payload: { + widgetType: "passage", + widgetSubType: "null", + widgetId: this.props.widgetId, + }, + }); } shouldComponentUpdate( @@ -541,10 +557,12 @@ export class Passage } } +const WrappedPassage = withDependencies(Passage); + export default { name: "passage", displayName: "Passage (SAT only)", hidden: true, - widget: Passage, + widget: WrappedPassage, isLintable: true, -} satisfies WidgetExports; +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/plotter/plotter.test.tsx b/packages/perseus/src/widgets/plotter/plotter.test.tsx index d09d71fd5bb..b925b07df15 100644 --- a/packages/perseus/src/widgets/plotter/plotter.test.tsx +++ b/packages/perseus/src/widgets/plotter/plotter.test.tsx @@ -1,7 +1,10 @@ import {scorePerseusItem} from "@khanacademy/perseus-score"; import {act, screen, waitFor} from "@testing-library/react"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; import {ApiOptions} from "../../perseus-api"; import {getAnswerfulItem, getAnswerlessItem} from "../../util/test-utils"; @@ -9,6 +12,7 @@ import {renderQuestion} from "../__testutils__/renderQuestion"; import {dotPlotter} from "./plotter.testdata"; +import type {PerseusDependenciesV2} from "../../types"; import type {PerseusPlotterWidgetOptions} from "@khanacademy/perseus-core"; describe("plotter widget", () => { @@ -25,6 +29,28 @@ describe("plotter widget", () => { expect(screen.getByText("Average Temp")).toBeInTheDocument(); }); + it("should send analytics event when widget is rendered", () => { + // Arrange + const onAnalyticsEventSpy = jest.fn(); + const depsV2: PerseusDependenciesV2 = { + ...testDependenciesV2, + analytics: {onAnalyticsEvent: onAnalyticsEventSpy}, + }; + + // Act + renderQuestion(dotPlotter, undefined, undefined, undefined, depsV2); + + // Assert + expect(onAnalyticsEventSpy).toHaveBeenCalledWith({ + type: "perseus:widget:rendered:ti", + payload: { + widgetSubType: "null", + widgetType: "plotter", + widgetId: "plotter 1", + }, + }); + }); + describe("drag text", () => { function sharedPlotterOptions(): PerseusPlotterWidgetOptions { return { diff --git a/packages/perseus/src/widgets/plotter/plotter.tsx b/packages/perseus/src/widgets/plotter/plotter.tsx index eda589e1957..b120edb5274 100644 --- a/packages/perseus/src/widgets/plotter/plotter.tsx +++ b/packages/perseus/src/widgets/plotter/plotter.tsx @@ -12,13 +12,19 @@ import * as React from "react"; import _ from "underscore"; import {PerseusI18nContext} from "../../components/i18n-context"; +import {withDependencies} from "../../components/with-dependencies"; import Interactive2 from "../../interactive2"; import WrappedLine from "../../interactive2/wrapped-line"; import KhanColors from "../../util/colors"; import GraphUtils from "../../util/graph-utils"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/plotter/plotter-ai-utils"; -import type {Widget, WidgetExports, WidgetProps} from "../../types"; +import type { + PerseusDependenciesV2, + Widget, + WidgetExports, + WidgetProps, +} from "../../types"; import type {UnsupportedWidgetPromptJSON} from "../../widget-ai-utils/unsupported-widget"; type Props = WidgetProps< @@ -27,6 +33,7 @@ type Props = WidgetProps< > & { labelInterval: NonNullable; picSize: NonNullable; + dependencies: PerseusDependenciesV2; }; type DefaultProps = { @@ -74,6 +81,15 @@ class Plotter extends React.Component implements Widget { this._isMounted = true; this.setupGraphie(this.props.userInput); + + this.props.dependencies.analytics.onAnalyticsEvent({ + type: "perseus:widget:rendered:ti", + payload: { + widgetType: "plotter", + widgetSubType: "null", + widgetId: this.props.widgetId, + }, + }); } UNSAFE_componentWillReceiveProps(nextProps: Props) { @@ -1188,12 +1204,14 @@ function getUserInputFromSerializedState( return serializedState.values; } +const WrappedPlotter = withDependencies(Plotter); + export default { name: "plotter", displayName: "Plotter", hidden: true, - widget: Plotter, + widget: WrappedPlotter, getCorrectUserInput, getStartUserInput, getUserInputFromSerializedState, -} satisfies WidgetExports; +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/plotter/serialize-plotter.test.ts b/packages/perseus/src/widgets/plotter/serialize-plotter.test.ts index 8377aec98b3..59765bc8715 100644 --- a/packages/perseus/src/widgets/plotter/serialize-plotter.test.ts +++ b/packages/perseus/src/widgets/plotter/serialize-plotter.test.ts @@ -4,7 +4,10 @@ import { } from "@khanacademy/perseus-core"; import {act} from "@testing-library/react"; -import {testDependencies} from "../../../../../testing/test-dependencies"; +import { + testDependencies, + testDependenciesV2, +} from "../../../../../testing/test-dependencies"; import {renderQuestion} from "../../__tests__/test-utils"; import * as Dependencies from "../../dependencies"; import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing"; @@ -107,6 +110,7 @@ describe("Plotter serialization", () => { picUrl: null, // manually added to serialized state values: [3, 3, 3], + dependencies: testDependenciesV2, }, }, hints: [], diff --git a/packages/perseus/src/widgets/radio/__tests__/multiple-choice.test.ts b/packages/perseus/src/widgets/radio/__tests__/multiple-choice.test.ts index a2ac62dc128..bb3d763e60b 100644 --- a/packages/perseus/src/widgets/radio/__tests__/multiple-choice.test.ts +++ b/packages/perseus/src/widgets/radio/__tests__/multiple-choice.test.ts @@ -12,7 +12,7 @@ import { import * as Dependencies from "../../../dependencies"; import {scorePerseusItemTesting} from "../../../util/test-utils"; import {renderQuestion} from "../../__testutils__/renderQuestion"; -import PassageWidget from "../../passage"; +import {Passage} from "../../passage"; import { questionAndAnswer, @@ -388,10 +388,11 @@ describe("Multiple Choice Widget", () => { // We mock this one function on Passage as its where all the magic DOM // measurement happens. This ensures our assertions in this test don't // have to assert NaN and make sense. - jest.spyOn( - PassageWidget.widget.prototype, - "getReference", - ).mockReturnValue({content: "", startLine: 1, endLine: 2}); + jest.spyOn(Passage.prototype, "getReference").mockReturnValue({ + content: "", + startLine: 1, + endLine: 2, + }); // Act renderQuestion(question, apiOptions); diff --git a/packages/perseus/src/widgets/radio/__tests__/radio.test.ts b/packages/perseus/src/widgets/radio/__tests__/radio.test.ts index 5a92d98cd5a..a780a667c25 100644 --- a/packages/perseus/src/widgets/radio/__tests__/radio.test.ts +++ b/packages/perseus/src/widgets/radio/__tests__/radio.test.ts @@ -8,7 +8,7 @@ import {testDependencies} from "../../../../../../testing/test-dependencies"; import * as Dependencies from "../../../dependencies"; import {scorePerseusItemTesting} from "../../../util/test-utils"; import {renderQuestion} from "../../__testutils__/renderQuestion"; -import PassageWidget from "../../passage"; +import {Passage} from "../../passage"; import { questionAndAnswer, @@ -378,10 +378,11 @@ describe("Radio Widget", () => { // We mock this one function on Passage as its where all the magic DOM // measurement happens. This ensures our assertions in this test don't // have to assert NaN and make sense. - jest.spyOn( - PassageWidget.widget.prototype, - "getReference", - ).mockReturnValue({content: "", startLine: 1, endLine: 2}); + jest.spyOn(Passage.prototype, "getReference").mockReturnValue({ + content: "", + startLine: 1, + endLine: 2, + }); // Act renderQuestion(question, apiOptions);