diff --git a/.changeset/empty-seals-worry.md b/.changeset/empty-seals-worry.md new file mode 100644 index 0000000000..7191a1a61b --- /dev/null +++ b/.changeset/empty-seals-worry.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Add PhET widget diff --git a/packages/perseus-editor/src/all-editors.ts b/packages/perseus-editor/src/all-editors.ts index a6427534f8..4084927675 100644 --- a/packages/perseus-editor/src/all-editors.ts +++ b/packages/perseus-editor/src/all-editors.ts @@ -27,6 +27,7 @@ import OrdererEditor from "./widgets/orderer-editor"; import PassageEditor from "./widgets/passage-editor"; import PassageRefEditor from "./widgets/passage-ref-editor"; import PassageRefTargetEditor from "./widgets/passage-ref-target-editor"; +import PhetSimEditor from "./widgets/phet-sim-editor"; import PlotterEditor from "./widgets/plotter-editor"; import PythonProgramEditor from "./widgets/python-program-editor"; import RadioEditor from "./widgets/radio/editor"; @@ -64,6 +65,7 @@ export default [ PassageEditor, PassageRefEditor, PassageRefTargetEditor, + PhetSimEditor, PlotterEditor, PythonProgramEditor, SimpleMarkdownTesterEditor, diff --git a/packages/perseus-editor/src/widgets/__stories__/phet-sim-editor.stories.tsx b/packages/perseus-editor/src/widgets/__stories__/phet-sim-editor.stories.tsx new file mode 100644 index 0000000000..62dcafc601 --- /dev/null +++ b/packages/perseus-editor/src/widgets/__stories__/phet-sim-editor.stories.tsx @@ -0,0 +1,19 @@ +import {action} from "@storybook/addon-actions"; + +import PhetSimEditor from "../phet-sim-editor"; + +import type {Meta, StoryObj} from "@storybook/react"; + +const meta: Meta = { + component: PhetSimEditor, + title: "PerseusEditor/Widgets/PhET Simulation Editor", +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + onChange: action("onChange"), + }, +}; diff --git a/packages/perseus-editor/src/widgets/phet-sim-editor.tsx b/packages/perseus-editor/src/widgets/phet-sim-editor.tsx new file mode 100644 index 0000000000..95fb4b0295 --- /dev/null +++ b/packages/perseus-editor/src/widgets/phet-sim-editor.tsx @@ -0,0 +1,46 @@ +/* eslint-disable @khanacademy/ts-no-error-suppressions */ +/* eslint-disable react/sort-comp */ +import {Changeable, EditorJsonify} from "@khanacademy/perseus"; +import * as React from "react"; + +import BlurInput from "../components/blur-input"; + +type PhetSimEditorProps = any; + +class PhetSimEditor extends React.Component { + static propTypes = { + ...Changeable.propTypes, + }; + + static widgetName = "phet-sim" as const; + + static defaultProps: PhetSimEditorProps = { + url: "", + description: "", + }; + + change: (arg1: any, arg2: any) => any = (...args) => { + return Changeable.change.apply(this, args); + }; + + render(): React.ReactNode { + return ( +
+ +
+ ); + } + + serialize: () => any = () => { + return EditorJsonify.serialize.call(this); + }; +} + +export default PhetSimEditor; diff --git a/packages/perseus/package.json b/packages/perseus/package.json index 79c8da5d03..508a950a4d 100644 --- a/packages/perseus/package.json +++ b/packages/perseus/package.json @@ -41,8 +41,8 @@ }, "dependencies": { "@khanacademy/kas": "^0.3.11", - "@khanacademy/kmath": "^0.1.13", "@khanacademy/keypad-context": "^1.0.0", + "@khanacademy/kmath": "^0.1.13", "@khanacademy/math-input": "^21.0.0", "@khanacademy/perseus-core": "1.5.0", "@khanacademy/perseus-linter": "^1.1.0", @@ -61,6 +61,7 @@ "@khanacademy/wonder-blocks-dropdown": "5.3.0", "@khanacademy/wonder-blocks-form": "^4.7.1", "@khanacademy/wonder-blocks-icon": "4.1.0", + "@khanacademy/wonder-blocks-icon-button": "^5.3.3", "@khanacademy/wonder-blocks-layout": "2.0.32", "@khanacademy/wonder-blocks-link": "6.1.1", "@khanacademy/wonder-blocks-pill": "2.2.1", 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..e06175401a 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>; @@ -286,6 +290,7 @@ export type PerseusWidget = | OrdererWidget | PassageRefWidget | PassageWidget + | PhetSimWidget | PlotterWidget | PythonProgramWidget | RadioWidget @@ -1536,6 +1541,15 @@ export type PerseusIFrameWidgetOptions = { static: boolean; }; +export type PerseusPhetSimWidgetOptions = { + // A URL to display, must start with https://phet.colorado.edu/ + url: 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 +1618,7 @@ export type PerseusWidgetOptions = | PerseusPassageRefTargetWidgetOptions | PerseusPassageRefWidgetOptions | PerseusPassageWidgetOptions + | PerseusPhetSimWidgetOptions | PerseusPlotterWidgetOptions | PerseusRadioWidgetOptions | PerseusSimpleMarkdownTesterWidgetOptions diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 41c35574ea..ca9686230c 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -125,6 +125,8 @@ export type PerseusStrings = { videoWrapper: string; mathInputTitle: string; mathInputDescription: string; + simulationLoadFail: string; + simulationLocaleWarning: string; }; /** @@ -291,6 +293,9 @@ export const strings: { mathInputTitle: "mathematics keyboard", mathInputDescription: "Use keyboard/mouse to interact with math-based input fields", + simulationLoadFail: "Sorry, this simulation cannot load.", + simulationLocaleWarning: + "Sorry, this simulation isn't available in your language.", }; /** @@ -441,4 +446,7 @@ export const mockStrings: PerseusStrings = { mathInputTitle: "mathematics keyboard", mathInputDescription: "Use keyboard/mouse to interact with math-based input fields", + simulationLoadFail: "Sorry, this simulation cannot load.", + simulationLocaleWarning: + "Sorry, this simulation isn't available in your language.", }; 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..7c4710cb8c --- /dev/null +++ b/packages/perseus/src/widgets/__stories__/phet-sim.stories.tsx @@ -0,0 +1,18 @@ +import {PhetSim} from "../phet-sim"; + +import type {Meta, StoryObj} from "@storybook/react"; + +const meta: Meta = { + component: PhetSim, + title: "Perseus/Widgets/PhET Simulation", +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + url: "https://phet.colorado.edu/sims/html/projectile-data-lab/latest/projectile-data-lab_all.html", + description: "Projectile Data Lab", + }, +}; 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..b26ac23bee --- /dev/null +++ b/packages/perseus/src/widgets/__testdata__/phet-sim.testdata.ts @@ -0,0 +1,40 @@ +import type {PerseusRenderer} from "../../perseus-types"; + +export const question1: PerseusRenderer = { + content: + "Do this fun PhET simulation! A projectile data lab!\n[[\u2603 phet-sim 1]]\n", + images: {}, + widgets: { + "phet-sim 1": { + graded: false, + 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", + description: "Projectile Data Lab", + static: false, + }, + alignment: "default", + }, + }, +}; + +export const nonPhetUrl: PerseusRenderer = { + content: "This should display an error!\n[[\u2603 phet-sim 2]]\n", + images: {}, + widgets: { + "phet-sim 2": { + graded: false, + version: {major: 0, minor: 0}, + static: false, + type: "phet-sim", + options: { + url: "https://google.com/", + description: "Google", + static: false, + }, + alignment: "default", + }, + }, +}; diff --git a/packages/perseus/src/widgets/__tests__/phet-sim.test.ts b/packages/perseus/src/widgets/__tests__/phet-sim.test.ts new file mode 100644 index 0000000000..37d746dc78 --- /dev/null +++ b/packages/perseus/src/widgets/__tests__/phet-sim.test.ts @@ -0,0 +1,91 @@ +import {screen, waitFor} from "@testing-library/react"; + +import {testDependencies} from "../../../../../testing/test-dependencies"; +import * as Dependencies from "../../dependencies"; +import {nonPhetUrl, question1} from "../__testdata__/phet-sim.testdata"; +import {makeSafeUrl} from "../phet-sim"; + +import {renderQuestion} from "./renderQuestion"; + +import type {APIOptions} from "../../types"; + +describe("phet-sim widget", () => { + beforeEach(() => { + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + en: {stringConstant: "localized string"}, + }), + ok: true, + }), + ) as jest.Mock; + global.URL.canParse = jest.fn(() => true) as jest.Mock; + }); + + it("should display with valid PhET URL", async () => { + // Arrange + const apiOptions: APIOptions = { + isMobile: false, + }; + + // Act + renderQuestion(question1, apiOptions); + + // Assert + await waitFor(() => { + expect(screen.queryByTitle("Projectile Data Lab")).toHaveAttribute( + "src", + "https://phet.colorado.edu/sims/html/projectile-data-lab/latest/projectile-data-lab_all.html?locale=en", + ); + }); + }); + + it("should display an error for a non-PhET URL", async () => { + // Arrange + const apiOptions: APIOptions = { + isMobile: false, + }; + + // Act + renderQuestion(nonPhetUrl, apiOptions); + + // Assert + await waitFor(() => { + expect(screen.queryByTitle("Google")).toHaveAttribute( + "srcDoc", + "Sorry, this simulation cannot load.", + ); + }); + }); + + it("should make the URL with the correct locale", async () => { + // Arrange + const baseUrl = + "https://phet.colorado.edu/sims/html/projectile-data-lab/latest/projectile-data-lab_all.html"; + const locale = "fr"; + + // Act + const url: URL | null = makeSafeUrl(baseUrl, locale); + + // Assert + expect(url?.toString()).toBe( + "https://phet.colorado.edu/sims/html/projectile-data-lab/latest/projectile-data-lab_all.html?locale=fr", + ); + }); + + it("should erase a non-PhET URL", async () => { + // Arrange + const baseUrl = "https://google.com"; + const locale = "en"; + + // Act + const url: URL | null = makeSafeUrl(baseUrl, locale); + + // Assert + expect(url).toBe(null); + }); +}); diff --git a/packages/perseus/src/widgets/phet-sim.tsx b/packages/perseus/src/widgets/phet-sim.tsx new file mode 100644 index 0000000000..0d13c22d6b --- /dev/null +++ b/packages/perseus/src/widgets/phet-sim.tsx @@ -0,0 +1,220 @@ +/* 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 Banner from "@khanacademy/wonder-blocks-banner"; +import {View} from "@khanacademy/wonder-blocks-core"; +import IconButton from "@khanacademy/wonder-blocks-icon-button"; +import cornersOutIcon from "@phosphor-icons/core/regular/corners-out.svg"; +import * as React from "react"; + +import {PerseusI18nContext} from "../components/i18n-context"; +import {getDependencies} from "../dependencies"; +import * as Changeable from "../mixins/changeable"; + +import type {PerseusPhetSimWidgetOptions} from "../perseus-types"; +import type {WidgetExports, WidgetProps} from "../types"; + +type RenderProps = PerseusPhetSimWidgetOptions; // transform = _.identity +type Props = WidgetProps; + +// For returning user input, but currently the PhET widget +// does not support accessing user input +type UserInput = null; + +type State = { + bannerMessage: string | null; + url: URL | null; +}; + +/* This renders the PhET sim */ +export class PhetSim extends React.Component { + static contextType = PerseusI18nContext; + declare context: React.ContextType; + private readonly iframeRef: React.RefObject = + React.createRef(); + private readonly locale: string; + + state: State = { + url: null, + bannerMessage: null, + }; + + constructor(props) { + super(props); + this.locale = this.getPhetCompatibleLocale(getDependencies().kaLocale); + } + + getUserInput(): UserInput { + return null; + } + + async componentDidMount() { + await this.updateSimState(this.props.url); + } + + async componentDidUpdate(prevProps) { + // If the URL has changed, update our state + if (prevProps.url !== this.props.url) { + await this.updateSimState(this.props.url); + } + } + + render(): React.ReactNode { + // We sandbox the iframe so that we allowlist only the functionality + // that we need. This makes it safer to present third-party content + // from the PhET website. + // http://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/ + const sandboxProperties = "allow-same-origin allow-scripts"; + return ( + + {this.state.bannerMessage && ( + // TODO(anna): Make this banner focusable + + )} +