diff --git a/.changeset/smooth-rice-speak.md b/.changeset/smooth-rice-speak.md new file mode 100644 index 0000000000..6630311e23 --- /dev/null +++ b/.changeset/smooth-rice-speak.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: split `MafsGraph` and `StatefulMafsGraph` into separate files. diff --git a/packages/perseus/src/widgets/interactive-graphs/index.ts b/packages/perseus/src/widgets/interactive-graphs/index.ts index 50bdcf7e8a..f6b9c91319 100644 --- a/packages/perseus/src/widgets/interactive-graphs/index.ts +++ b/packages/perseus/src/widgets/interactive-graphs/index.ts @@ -1 +1 @@ -export {StatefulMafsGraph} from "./mafs-graph"; +export {StatefulMafsGraph} from "./stateful-mafs-graph"; diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx index 19f5b9eefb..c5e852415c 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx @@ -7,11 +7,11 @@ import invariant from "tiny-invariant"; import {testDependencies} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; -import {MafsGraph, StatefulMafsGraph} from "./mafs-graph"; +import {MafsGraph} from "./mafs-graph"; import {movePoint} from "./reducer/interactive-graph-action"; import {interactiveGraphReducer} from "./reducer/interactive-graph-reducer"; -import type {MafsGraphProps, StatefulMafsGraphProps} from "./mafs-graph"; +import type {MafsGraphProps} from "./mafs-graph"; import type {InteractiveGraphState} from "./types"; import type {GraphRange} from "../../perseus-types"; import type {UserEvent} from "@testing-library/user-event"; @@ -40,26 +40,6 @@ function getBaseMafsGraphProps(): MafsGraphProps { }; } -function getBaseStatefulMafsGraphProps(): StatefulMafsGraphProps { - return { - box: [400, 400], - step: [1, 1], - snapStep: [1, 1], - gridStep: [1, 1], - range: [ - [-10, 10], - [-10, 10], - ], - markings: "graph", - containerSizeClass: "small", - onChange: () => {}, - showTooltips: false, - showProtractor: false, - labels: ["x", "y"], - graph: {type: "segment"}, - }; -} - function createFakeStore(reducer: (state: S, action: A) => S, state: S) { return { dispatch(action: A) { @@ -72,108 +52,6 @@ function createFakeStore(reducer: (state: S, action: A) => S, state: S) { }; } -describe("StatefulMafsGraph", () => { - let userEvent: UserEvent; - beforeEach(() => { - jest.spyOn(Dependencies, "getDependencies").mockReturnValue( - testDependencies, - ); - userEvent = userEventLib.setup({ - advanceTimers: jest.advanceTimersByTime, - }); - }); - - it("renders", () => { - const {container} = render( - , - ); - - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const movablePoints = container.querySelectorAll( - "circle.movable-point-hitbox", - ); - - expect(movablePoints).not.toBe(0); - }); - - it("calls onChange when using graph", async () => { - const mockChangeHandler = jest.fn(); - - render( - , - ); - - await userEvent.tab(); - await userEvent.keyboard("{arrowup}"); - - expect(mockChangeHandler).toHaveBeenCalled(); - }); - - it("re-renders when the graph type changes", () => { - // Arrange: render a segment graph - const segmentGraphProps: StatefulMafsGraphProps = { - ...getBaseStatefulMafsGraphProps(), - graph: {type: "segment"}, - }; - const {rerender} = render(); - - // Act: rerender with a quadratic graph - const quadraticGraphProps: StatefulMafsGraphProps = { - ...getBaseStatefulMafsGraphProps(), - graph: {type: "quadratic"}, - }; - rerender(); - - // Assert: there should be 3 movable points (which define the quadratic - // function). If there are 2 points, it means we are still rendering - // the segment graph. - expect(screen.getAllByTestId("movable-point").length).toBe(3); - }); - - it("re-renders when the number of line segments on a segment graph changes", () => { - // Arrange: render a segment graph with one segment - const oneSegmentProps: StatefulMafsGraphProps = { - ...getBaseStatefulMafsGraphProps(), - graph: {type: "segment", numSegments: 1}, - }; - const {rerender} = render(); - - // Act: rerender with two segments - const twoSegmentProps: StatefulMafsGraphProps = { - ...getBaseStatefulMafsGraphProps(), - graph: {type: "segment", numSegments: 2}, - }; - rerender(); - - // Assert: there should be 4 movable points. If there are 2 points, it - // means we are still rendering a single segment. - expect(screen.getAllByTestId("movable-point").length).toBe(4); - }); - - it("re-renders when the number of sides on a polygon graph changes", () => { - // Arrange: render a polygon graph with three sides - const threeSidesProps: StatefulMafsGraphProps = { - ...getBaseStatefulMafsGraphProps(), - graph: {type: "polygon", numSides: 3}, - }; - const {rerender} = render(); - - // Act: rerender with four sides - const fourSidesProps: StatefulMafsGraphProps = { - ...getBaseStatefulMafsGraphProps(), - graph: {type: "polygon", numSides: 4}, - }; - rerender(); - - // Assert: there should be 4 movable points. If there are 3 points, it - // means we are still rendering only 3 sides. - expect(screen.getAllByTestId("movable-point").length).toBe(4); - }); -}); - function graphToPixel( point: vec.Vector2, range: GraphRange = [ diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx index 17e1b1048d..0c42f0d5b6 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx @@ -1,8 +1,7 @@ -import {useLatestRef, View} from "@khanacademy/wonder-blocks-core"; +import {View} from "@khanacademy/wonder-blocks-core"; import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; import {Mafs} from "mafs"; import * as React from "react"; -import {useEffect, useImperativeHandle, useRef} from "react"; import AxisLabels from "./axis-labels"; import GraphLockedLayer from "./graph-locked-layer"; @@ -24,168 +23,15 @@ import {Grid} from "./grid"; import {LegacyGrid} from "./legacy-grid"; import {X, Y} from "./math"; import {Protractor} from "./protractor"; -import {initializeGraphState} from "./reducer/initialize-graph-state"; -import { - changeRange, - changeSnapStep, - reinitialize, - type InteractiveGraphAction, -} from "./reducer/interactive-graph-action"; -import {interactiveGraphReducer} from "./reducer/interactive-graph-reducer"; -import {getGradableGraph, getRadius} from "./reducer/interactive-graph-state"; +import {type InteractiveGraphAction} from "./reducer/interactive-graph-action"; import {GraphConfigContext} from "./reducer/use-graph-config"; import type {InteractiveGraphState, InteractiveGraphProps} from "./types"; -import type {PerseusGraphType} from "../../perseus-types"; -import type {Widget} from "../../renderer"; import type {vec} from "mafs"; import "mafs/core.css"; import "./mafs-styles.css"; -export type StatefulMafsGraphProps = { - box: [number, number]; - backgroundImage?: InteractiveGraphProps["backgroundImage"]; - graph: PerseusGraphType; - lockedFigures?: InteractiveGraphProps["lockedFigures"]; - range: InteractiveGraphProps["range"]; - snapStep: InteractiveGraphProps["snapStep"]; - step: InteractiveGraphProps["step"]; - gridStep: InteractiveGraphProps["gridStep"]; - containerSizeClass: InteractiveGraphProps["containerSizeClass"]; - markings: InteractiveGraphProps["markings"]; - onChange: InteractiveGraphProps["onChange"]; - showTooltips: Required; - showProtractor: boolean; - labels: InteractiveGraphProps["labels"]; -}; - -type MafsChange = { - graph: InteractiveGraphState; -}; - -const renderGraph = (props: { - state: InteractiveGraphState; - dispatch: (action: InteractiveGraphAction) => unknown; -}) => { - const {state, dispatch} = props; - const {type} = state; - switch (type) { - case "angle": - return ; - case "segment": - return ; - case "linear-system": - return ; - case "linear": - return ; - case "ray": - return ; - case "polygon": - return ; - case "point": - return ; - case "circle": - return ; - case "quadratic": - return ; - case "sinusoid": - return ; - default: - return new UnreachableCaseError(type); - } -}; - -// Rather than be tightly bound to how data was structured in -// the legacy interactive graph, this lets us store state -// however we want and we just transform it before handing it off -// the the parent InteractiveGraph -function mafsStateToInteractiveGraph(state: MafsChange) { - if (state.graph.type === "circle") { - return { - ...state, - graph: { - ...state.graph, - radius: getRadius(state.graph), - }, - }; - } - return { - ...state, - }; -} - -export const StatefulMafsGraph = React.forwardRef< - Partial, - StatefulMafsGraphProps ->((props, ref) => { - const {onChange, graph} = props; - - const [state, dispatch] = React.useReducer( - interactiveGraphReducer, - props, - initializeGraphState, - ); - - useImperativeHandle(ref, () => ({ - getUserInput: () => getGradableGraph(state, graph), - })); - - const prevState = useRef(state); - - useEffect(() => { - if (prevState.current !== state) { - onChange(mafsStateToInteractiveGraph({graph: state})); - } - prevState.current = state; - }, [onChange, state]); - - // Destructuring first to keep useEffect from making excess calls - const [xSnap, ySnap] = props.snapStep; - useEffect(() => { - dispatch(changeSnapStep([xSnap, ySnap])); - }, [dispatch, xSnap, ySnap]); - - // Destructuring first to keep useEffect from making excess calls - const [[xMinRange, xMaxRange], [yMinRange, yMaxRange]] = props.range; - useEffect(() => { - dispatch( - changeRange([ - [xMinRange, xMaxRange], - [yMinRange, yMaxRange], - ]), - ); - }, [dispatch, xMinRange, xMaxRange, yMinRange, yMaxRange]); - - const numSegments = graph.type === "segment" ? graph.numSegments : null; - const numSides = graph.type === "polygon" ? graph.numSides : null; - const snapTo = graph.type === "polygon" ? graph.snapTo : null; - const showAngles = graph.type === "polygon" ? graph.showAngles : null; - const showSides = graph.type === "polygon" ? graph.showSides : null; - - const originalPropsRef = useRef(props); - const latestPropsRef = useLatestRef(props); - useEffect(() => { - // This conditional prevents the state from being "reinitialized" right - // after the first render. This is an optimization, but also prevents - // a bug where the graph would be marked "incorrect" during grading - // even if the user never interacted with it. - if (latestPropsRef.current !== originalPropsRef.current) { - dispatch(reinitialize(latestPropsRef.current)); - } - }, [ - graph.type, - numSegments, - numSides, - snapTo, - showAngles, - showSides, - latestPropsRef, - ]); - - return ; -}); - export type MafsGraphProps = { box: [number, number]; backgroundImage?: InteractiveGraphProps["backgroundImage"]; @@ -291,3 +137,35 @@ export const MafsGraph = (props: MafsGraphProps) => { ); }; + +const renderGraph = (props: { + state: InteractiveGraphState; + dispatch: (action: InteractiveGraphAction) => unknown; +}) => { + const {state, dispatch} = props; + const {type} = state; + switch (type) { + case "angle": + return ; + case "segment": + return ; + case "linear-system": + return ; + case "linear": + return ; + case "ray": + return ; + case "polygon": + return ; + case "point": + return ; + case "circle": + return ; + case "quadratic": + return ; + case "sinusoid": + return ; + default: + return new UnreachableCaseError(type); + } +}; diff --git a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.test.tsx new file mode 100644 index 0000000000..9b2a3fded8 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.test.tsx @@ -0,0 +1,133 @@ +import {render, screen} from "@testing-library/react"; +import {userEvent as userEventLib} from "@testing-library/user-event"; +import React from "react"; + +import {testDependencies} from "../../../../../testing/test-dependencies"; +import * as Dependencies from "../../dependencies"; + +import {StatefulMafsGraph} from "./stateful-mafs-graph"; + +import type {StatefulMafsGraphProps} from "./stateful-mafs-graph"; +import type {UserEvent} from "@testing-library/user-event"; + +function getBaseStatefulMafsGraphProps(): StatefulMafsGraphProps { + return { + box: [400, 400], + step: [1, 1], + snapStep: [1, 1], + gridStep: [1, 1], + range: [ + [-10, 10], + [-10, 10], + ], + markings: "graph", + containerSizeClass: "small", + onChange: () => {}, + showTooltips: false, + showProtractor: false, + labels: ["x", "y"], + graph: {type: "segment"}, + }; +} + +describe("StatefulMafsGraph", () => { + let userEvent: UserEvent; + beforeEach(() => { + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + }); + + it("renders", () => { + const {container} = render( + , + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const movablePoints = container.querySelectorAll( + "circle.movable-point-hitbox", + ); + + expect(movablePoints).not.toBe(0); + }); + + it("calls onChange when using graph", async () => { + const mockChangeHandler = jest.fn(); + + render( + , + ); + + await userEvent.tab(); + await userEvent.keyboard("{arrowup}"); + + expect(mockChangeHandler).toHaveBeenCalled(); + }); + + it("re-renders when the graph type changes", () => { + // Arrange: render a segment graph + const segmentGraphProps: StatefulMafsGraphProps = { + ...getBaseStatefulMafsGraphProps(), + graph: {type: "segment"}, + }; + const {rerender} = render(); + + // Act: rerender with a quadratic graph + const quadraticGraphProps: StatefulMafsGraphProps = { + ...getBaseStatefulMafsGraphProps(), + graph: {type: "quadratic"}, + }; + rerender(); + + // Assert: there should be 3 movable points (which define the quadratic + // function). If there are 2 points, it means we are still rendering + // the segment graph. + expect(screen.getAllByTestId("movable-point").length).toBe(3); + }); + + it("re-renders when the number of line segments on a segment graph changes", () => { + // Arrange: render a segment graph with one segment + const oneSegmentProps: StatefulMafsGraphProps = { + ...getBaseStatefulMafsGraphProps(), + graph: {type: "segment", numSegments: 1}, + }; + const {rerender} = render(); + + // Act: rerender with two segments + const twoSegmentProps: StatefulMafsGraphProps = { + ...getBaseStatefulMafsGraphProps(), + graph: {type: "segment", numSegments: 2}, + }; + rerender(); + + // Assert: there should be 4 movable points. If there are 2 points, it + // means we are still rendering a single segment. + expect(screen.getAllByTestId("movable-point").length).toBe(4); + }); + + it("re-renders when the number of sides on a polygon graph changes", () => { + // Arrange: render a polygon graph with three sides + const threeSidesProps: StatefulMafsGraphProps = { + ...getBaseStatefulMafsGraphProps(), + graph: {type: "polygon", numSides: 3}, + }; + const {rerender} = render(); + + // Act: rerender with four sides + const fourSidesProps: StatefulMafsGraphProps = { + ...getBaseStatefulMafsGraphProps(), + graph: {type: "polygon", numSides: 4}, + }; + rerender(); + + // Assert: there should be 4 movable points. If there are 3 points, it + // means we are still rendering only 3 sides. + expect(screen.getAllByTestId("movable-point").length).toBe(4); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx new file mode 100644 index 0000000000..1e2585e94b --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx @@ -0,0 +1,124 @@ +import {useLatestRef} from "@khanacademy/wonder-blocks-core"; +import * as React from "react"; +import {useEffect, useImperativeHandle, useRef} from "react"; + +import {MafsGraph} from "./mafs-graph"; +import {initializeGraphState} from "./reducer/initialize-graph-state"; +import { + changeRange, + changeSnapStep, + reinitialize, +} from "./reducer/interactive-graph-action"; +import {interactiveGraphReducer} from "./reducer/interactive-graph-reducer"; +import {getGradableGraph, getRadius} from "./reducer/interactive-graph-state"; + +import type {InteractiveGraphProps, InteractiveGraphState} from "./types"; +import type {Widget} from "../../renderer"; +import type {PerseusGraphType} from "@khanacademy/perseus"; + +export type StatefulMafsGraphProps = { + box: [number, number]; + backgroundImage?: InteractiveGraphProps["backgroundImage"]; + graph: PerseusGraphType; + lockedFigures?: InteractiveGraphProps["lockedFigures"]; + range: InteractiveGraphProps["range"]; + snapStep: InteractiveGraphProps["snapStep"]; + step: InteractiveGraphProps["step"]; + gridStep: InteractiveGraphProps["gridStep"]; + containerSizeClass: InteractiveGraphProps["containerSizeClass"]; + markings: InteractiveGraphProps["markings"]; + onChange: InteractiveGraphProps["onChange"]; + showTooltips: Required; + showProtractor: boolean; + labels: InteractiveGraphProps["labels"]; +}; + +// Rather than be tightly bound to how data was structured in +// the legacy interactive graph, this lets us store state +// however we want and we just transform it before handing it off +// the the parent InteractiveGraph +function mafsStateToInteractiveGraph(state: {graph: InteractiveGraphState}) { + if (state.graph.type === "circle") { + return { + ...state, + graph: { + ...state.graph, + radius: getRadius(state.graph), + }, + }; + } + return { + ...state, + }; +} + +export const StatefulMafsGraph = React.forwardRef< + Partial, + StatefulMafsGraphProps +>((props, ref) => { + const {onChange, graph} = props; + + const [state, dispatch] = React.useReducer( + interactiveGraphReducer, + props, + initializeGraphState, + ); + + useImperativeHandle(ref, () => ({ + getUserInput: () => getGradableGraph(state, graph), + })); + + const prevState = useRef(state); + + useEffect(() => { + if (prevState.current !== state) { + onChange(mafsStateToInteractiveGraph({graph: state})); + } + prevState.current = state; + }, [onChange, state]); + + // Destructuring first to keep useEffect from making excess calls + const [xSnap, ySnap] = props.snapStep; + useEffect(() => { + dispatch(changeSnapStep([xSnap, ySnap])); + }, [dispatch, xSnap, ySnap]); + + // Destructuring first to keep useEffect from making excess calls + const [[xMinRange, xMaxRange], [yMinRange, yMaxRange]] = props.range; + useEffect(() => { + dispatch( + changeRange([ + [xMinRange, xMaxRange], + [yMinRange, yMaxRange], + ]), + ); + }, [dispatch, xMinRange, xMaxRange, yMinRange, yMaxRange]); + + const numSegments = graph.type === "segment" ? graph.numSegments : null; + const numSides = graph.type === "polygon" ? graph.numSides : null; + const snapTo = graph.type === "polygon" ? graph.snapTo : null; + const showAngles = graph.type === "polygon" ? graph.showAngles : null; + const showSides = graph.type === "polygon" ? graph.showSides : null; + + const originalPropsRef = useRef(props); + const latestPropsRef = useLatestRef(props); + useEffect(() => { + // This conditional prevents the state from being "reinitialized" right + // after the first render. This is an optimization, but also prevents + // a bug where the graph would be marked "incorrect" during grading + // even if the user never interacted with it. + if (latestPropsRef.current !== originalPropsRef.current) { + dispatch(reinitialize(latestPropsRef.current)); + } + }, [ + graph.type, + numSegments, + numSides, + snapTo, + showAngles, + showSides, + latestPropsRef, + ]); + + return ; +});