From 1e69d6b9e514ffb7b6e49e53d3928edef46c299b Mon Sep 17 00:00:00 2001 From: Anna Mistele Date: Mon, 15 Jul 2024 15:57:03 -0700 Subject: [PATCH 01/33] Initial widget creation --- packages/perseus/src/extra-widgets.ts | 2 + packages/perseus/src/perseus-types.ts | 18 +++ .../perseus/src/util/widget-enum-utils.ts | 3 + .../widgets/__stories__/phet-sim.stories.tsx | 14 ++ .../widgets/__testdata__/phet-sim.testdata.ts | 28 ++++ packages/perseus/src/widgets/phet-sim.tsx | 128 ++++++++++++++++++ 6 files changed, 193 insertions(+) create mode 100644 packages/perseus/src/widgets/__stories__/phet-sim.stories.tsx create mode 100644 packages/perseus/src/widgets/__testdata__/phet-sim.testdata.ts create mode 100644 packages/perseus/src/widgets/phet-sim.tsx diff --git a/packages/perseus/src/extra-widgets.ts b/packages/perseus/src/extra-widgets.ts index f5cecbb36c..bb48926e63 100644 --- a/packages/perseus/src/extra-widgets.ts +++ b/packages/perseus/src/extra-widgets.ts @@ -26,6 +26,7 @@ import Orderer from "./widgets/orderer"; import Passage from "./widgets/passage"; import PassageRef from "./widgets/passage-ref"; import PassageRefTarget from "./widgets/passage-ref-target"; +import PhetSim from "./widgets/phet-sim"; import Plotter from "./widgets/plotter"; import PythonProgram from "./widgets/python-program"; import Sorter from "./widgets/sorter"; @@ -58,6 +59,7 @@ export default [ Passage, PassageRef, PassageRefTarget, + PhetSim, Plotter, PythonProgram, Sorter, diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index e3582b2ca7..bfde7f6ad2 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -67,6 +67,8 @@ export type PerseusWidgetsMap = { [key in `passage-ref ${number}`]: PassageRefWidget; } & { [key in `passage-ref-target ${number}`]: PassageRefWidget; +} & { + [key in `phet-sim ${number}`]: PhetSimWidget; } & { [key in `plotter ${number}`]: PlotterWidget; } & { @@ -232,6 +234,8 @@ export type PassageRefWidget = Widget<'passage-ref', PerseusPassageRefWidgetOpti // prettier-ignore export type PassageWidget = Widget<'passage', PerseusPassageWidgetOptions>; // prettier-ignore +export type PhetSimWidget = Widget<'phet-sim', PerseusPhetSimWidgetOptions>; +// prettier-ignore export type PlotterWidget = Widget<'plotter', PerseusPlotterWidgetOptions>; // prettier-ignore export type PythonProgramWidget = Widget<'python-program', PerseusPythonProgramWidgetOptions>; @@ -1536,6 +1540,19 @@ export type PerseusIFrameWidgetOptions = { static: boolean; }; +export type PerseusPhetSimWidgetOptions = { + // A URL to display, must start with https://phet.colorado.edu/ + url: string; + // The width of the widget + width?: number | string; + // The height of the widget + height?: number | string; + // Translatable Text; Description of the sim for Khanmigo and alt text + description: string; + // Always false + static: boolean; +}; + export type PerseusVideoWidgetOptions = { location: string; static?: boolean; @@ -1604,6 +1621,7 @@ export type PerseusWidgetOptions = | PerseusPassageRefTargetWidgetOptions | PerseusPassageRefWidgetOptions | PerseusPassageWidgetOptions + | PerseusPhetSimWidgetOptions | PerseusPlotterWidgetOptions | PerseusRadioWidgetOptions | PerseusSimpleMarkdownTesterWidgetOptions diff --git a/packages/perseus/src/util/widget-enum-utils.ts b/packages/perseus/src/util/widget-enum-utils.ts index f549754f21..9db395676d 100644 --- a/packages/perseus/src/util/widget-enum-utils.ts +++ b/packages/perseus/src/util/widget-enum-utils.ts @@ -34,6 +34,7 @@ type WidgetName = | "passage" | "passage-ref" | "passage-ref-target" + | "phet-sim" | "plotter" | "python-program" | "reaction-diagram" @@ -73,6 +74,7 @@ type WidgetEnum = | "PASSAGE" | "PASSAGE_REF" | "PASSAGE_REF_TARGET" + | "PHET_SIM" | "PLOTTER" | "PYTHON_PROGRAM" | "REACTION_DIAGRAM" @@ -113,6 +115,7 @@ const widgetNameToEnum: Record = { passage: "PASSAGE", "passage-ref": "PASSAGE_REF", "passage-ref-target": "PASSAGE_REF_TARGET", + "phet-sim": "PHET_SIM", plotter: "PLOTTER", "python-program": "PYTHON_PROGRAM", "reaction-diagram": "REACTION_DIAGRAM", diff --git a/packages/perseus/src/widgets/__stories__/phet-sim.stories.tsx b/packages/perseus/src/widgets/__stories__/phet-sim.stories.tsx new file mode 100644 index 0000000000..eddf0fcafa --- /dev/null +++ b/packages/perseus/src/widgets/__stories__/phet-sim.stories.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import {RendererWithDebugUI} from "../../../../../testing/renderer-with-debug-ui"; +import {question1} from "../__testdata__/phet-sim.testdata"; + +export default { + title: "Perseus/Widgets/PhetSim", +}; + +type StoryArgs = Record; + +export const Question1 = (args: StoryArgs): React.ReactElement => { + return ; +}; diff --git a/packages/perseus/src/widgets/__testdata__/phet-sim.testdata.ts b/packages/perseus/src/widgets/__testdata__/phet-sim.testdata.ts new file mode 100644 index 0000000000..1937b83955 --- /dev/null +++ b/packages/perseus/src/widgets/__testdata__/phet-sim.testdata.ts @@ -0,0 +1,28 @@ +import type {PerseusRenderer} from "../../perseus-types"; + +export const question1: PerseusRenderer = { + content: "Try matching the target image\n[[\u2603 phet-sim 1]]\n", + images: { + "https://ka-perseus-images.s3.amazonaws.com/8e518475587bc83767c72b49ff094e5870c3edc3.png": + { + width: 760, + height: 688, + }, + }, + widgets: { + "phet-sim 1": { + graded: true, + version: {major: 0, minor: 0}, + static: false, + type: "phet-sim", + options: { + url: "https://phet.colorado.edu/sims/html/projectile-data-lab/latest/projectile-data-lab_all.html", + height: "410", + width: "410", + description: "Projectile Data Lab", + static: false, + }, + alignment: "default", + }, + }, +}; diff --git a/packages/perseus/src/widgets/phet-sim.tsx b/packages/perseus/src/widgets/phet-sim.tsx new file mode 100644 index 0000000000..b2ae49ebe7 --- /dev/null +++ b/packages/perseus/src/widgets/phet-sim.tsx @@ -0,0 +1,128 @@ +/* eslint-disable react/forbid-prop-types, react/sort-comp */ +/** + * This is a PhET simulation widget. It is used for rendering simulations + * from https://phet.colorado.edu/. + */ + +import $ from "jquery"; +import PropTypes from "prop-types"; +import * as React from "react"; +import _ from "underscore"; + +import {getDependencies} from "../dependencies"; +import * as Changeable from "../mixins/changeable"; +import Util from "../util"; + +import type {WidgetExports} from "../types"; + +const {updateQueryString} = Util; + +/* This renders the PhET sim */ +class PhetSim extends React.Component { + static propTypes = { + ...Changeable.propTypes, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + url: PropTypes.string, + description: PropTypes.string, + }; + + getUserInput: () => any = () => { + return null; + }; + + handleMessageEvent: (arg1: any) => void = (e) => { + // We receive data from the iframe that contains {passed: true/false} + // and use that to set the status + // It could also contain an optional message + let data: Record = {}; + try { + data = JSON.parse(e.originalEvent.data); + } catch (err: any) { + return; + } + + if (_.isUndefined(data.testsPassed)) { + return; + } + + const status = data.testsPassed ? "correct" : "incorrect"; + this.change({ + status: status, + message: data.message, + }); + }; + + componentDidMount() { + $(window).on("message", this.handleMessageEvent); + } + + componentWillUnmount() { + $(window).off("message", this.handleMessageEvent); + } + + render(): React.ReactNode { + const style = { + width: String(this.props.width), + height: String(this.props.height), + } as const; + + const {kaLocale} = getDependencies(); + + // Add "px" to unitless numbers + Object.entries(style).forEach(([key, value]: [any, any]) => { + if (!value.endsWith("%") && !value.endsWith("px")) { + style[key] = value + "px"; + } + }); + + let url = this.props.url; + + // The URL needs to start with https://phet.colorado.edu/ + // Do we want to allow users to paste in a relative path instead of + // just a full link? + if ( + url && + url.length && + !url.startsWith("https://phet.colorado.edu/") // todo: find better check + ) { + // todo(anna): Error state, unable to provide content + // Figure out fallback + } + url = updateQueryString(url, "locale", kaLocale); + + const sandboxProperties = "allow-same-origin allow-scripts"; + + // We sandbox the iframe so that we allowlist only the functionality + // that we need. This makes it a bit safer in case some content + // creator "went wild". + // http://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/ + return ( +