diff --git a/.changeset/few-boats-retire.md b/.changeset/few-boats-retire.md new file mode 100644 index 0000000000..e4bc50c3b6 --- /dev/null +++ b/.changeset/few-boats-retire.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-editor": minor +--- + +Interactive Graph - Add example functions for copy/paste to locked functions settings diff --git a/packages/perseus-editor/src/components/__tests__/locked-function-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-function-settings.test.tsx index feaa499409..76927e3cfb 100644 --- a/packages/perseus-editor/src/components/__tests__/locked-function-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/locked-function-settings.test.tsx @@ -3,6 +3,9 @@ import {render, screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; import * as React from "react"; +// Disabling the following linting error because the import is needed for mocking purposes. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import examples from "../graph-locked-figures/locked-function-examples"; import LockedFunctionSettings from "../graph-locked-figures/locked-function-settings"; import {getDefaultFigureForType} from "../util"; @@ -16,6 +19,14 @@ const defaultProps = { onRemove: () => {}, } as Props; +const exampleEquationsMock = { + foo: ["bar", "zot"], +}; +jest.mock("../graph-locked-figures/locked-function-examples", () => ({ + __esModule: true, + default: exampleEquationsMock, +})); + describe("Locked Function Settings", () => { let userEvent: UserEvent; const onChangeProps = jest.fn(); @@ -222,7 +233,6 @@ describe("Locked Function Settings", () => { test("calls 'onChangeProps' when directional axis is changed", async () => { // Arrange - const onChangeProps = jest.fn(); render( { expect(onChangeProps).toHaveBeenCalledWith({directionalAxis: "y"}); }); - describe("Domain interactions", () => { + describe("Domain/Range interactions", () => { test("valid entries update component properties", async () => { // Arrange render( @@ -363,6 +373,138 @@ describe("Locked Function Settings", () => { domain: [3, Infinity], }); }); + + test("restriction labels reflect the directional axis specified", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Assert - "x" axis means "domain" + let minField = screen.getByText("domain min"); + expect(minField).toBeInTheDocument(); + + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Assert - "y" axis means "range" + minField = screen.getByText("range min"); + expect(minField).toBeInTheDocument(); + }); + }); + + describe("Example equation interactions", () => { + test("shows example equations based upon the category chosen", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Assert - initial state (no category selected) + let copyButtons = screen.queryAllByLabelText("copy example"); + expect(copyButtons.length).toEqual(0); + + // Act - choose a category of examples + const categoryDropdown = + screen.getByLabelText("Choose a category"); + await userEvent.click(categoryDropdown); + const categoryOption = screen.getAllByRole("option")[0]; + await userEvent.click(categoryOption); + + // Assert - modified state + copyButtons = screen.queryAllByLabelText("copy example"); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + test("example equation is copied to the clipboard when 'copy' icon button is activated", async () => { + // Arrange + const writeTextMock = jest.fn(); + const clipboardFnMock = jest.fn(); + jest.spyOn( + global.navigator, + "clipboard", + "get", + ).mockReturnValue({ + // Only interested in the "writeText" function. + writeText: writeTextMock, + // The other functions are here to avoid TS from complaining. + read: clipboardFnMock, + readText: clipboardFnMock, + write: clipboardFnMock, + addEventListener: clipboardFnMock, + dispatchEvent: clipboardFnMock, + removeEventListener: clipboardFnMock, + }); + + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act - choose a category to get a listing of examples + const categoryDropdown = + screen.getByLabelText("Choose a category"); + await userEvent.click(categoryDropdown); + const categoryOption = screen.getAllByRole("option")[0]; + await userEvent.click(categoryOption); + + // Act - click the copy button for the first example + const copyButton = screen.getAllByLabelText("copy example")[0]; + await userEvent.click(copyButton); + + // Assert - clipboard receives example text + expect(writeTextMock).toHaveBeenCalledWith("bar"); + }); + + test("example equation is copied to the equation field when 'paste' icon button is activated", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act - choose a category to get a listing of examples + const categoryDropdown = + screen.getByLabelText("Choose a category"); + await userEvent.click(categoryDropdown); + const categoryOption = screen.getAllByRole("option")[0]; + await userEvent.click(categoryOption); + + // Act - click the copy button for the first example + const pasteButton = + screen.getAllByLabelText("paste example")[0]; + await userEvent.click(pasteButton); + + // Assert - clipboard receives example text + expect(onChangeProps).toHaveBeenCalledWith({equation: "bar"}); + }); }); }); }); diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-function-examples.ts b/packages/perseus-editor/src/components/graph-locked-figures/locked-function-examples.ts new file mode 100644 index 0000000000..94c108586e --- /dev/null +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-function-examples.ts @@ -0,0 +1,7 @@ +const examples: {[k: string]: string[]} = { + linear: ["x + 5", "1/2x - 2"], + polynomial: ["1/2x^2 + 3x - 4", "(1/3)x^3 - 2x^2 + 3x - 4"], + trigonometric: ["sin(x) * 3", "arctan(2x) + 4"], +}; + +export default examples; diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-function-settings.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-function-settings.tsx index 5e192214e0..fa0231a575 100644 --- a/packages/perseus-editor/src/components/graph-locked-figures/locked-function-settings.tsx +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-function-settings.tsx @@ -7,12 +7,15 @@ import {View} from "@khanacademy/wonder-blocks-core"; import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; import {TextField} from "@khanacademy/wonder-blocks-form"; +import IconButton from "@khanacademy/wonder-blocks-icon-button"; import {Strut} from "@khanacademy/wonder-blocks-layout"; -import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; import {LabelLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography"; -import {StyleSheet} from "aphrodite"; +import copyIcon from "@phosphor-icons/core/assets/regular/copy.svg"; +import autoPasteIcon from "@phosphor-icons/core/assets/regular/note-pencil.svg"; +import {StyleSheet, css} from "aphrodite"; import * as React from "react"; -import {useEffect, useState} from "react"; +import {useEffect, useId, useState} from "react"; import PerseusEditorAccordion from "../perseus-editor-accordion"; @@ -20,6 +23,7 @@ import ColorSelect from "./color-select"; import LineStrokeSelect from "./line-stroke-select"; import LineSwatch from "./line-swatch"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; +import examples from "./locked-function-examples"; import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings"; import type {LockedFunctionType} from "@khanacademy/perseus"; @@ -45,9 +49,10 @@ const LockedFunctionSettings = (props: Props) => { onRemove, } = props; const equationPrefix = directionalAxis === "x" ? "y=" : "x="; + const domainRangeText = directionalAxis === "x" ? "domain" : "range"; const lineLabel = `Function (${equationPrefix}${equation})`; - // Tracking the string value of domain constraints to handle interim state of + // Tracking the string value of domain/range constraints to handle interim state of // entering a negative value, as well as representing Infinity as an empty string. // This variable is used when specifying the values of the input fields. const [domainEntries, setDomainEntries] = useState([ @@ -55,15 +60,17 @@ const LockedFunctionSettings = (props: Props) => { domain && domain[1] !== Infinity ? domain[1].toString() : "", ]); + const [exampleCategory, setExampleCategory] = useState(""); + useEffect(() => { - // "useEffect" used to maintain parity between domain constraints and their string representation. + // "useEffect" used to maintain parity between domain/range constraints and their string representation. setDomainEntries([ domain && domain[0] !== -Infinity ? domain[0].toString() : "", domain && domain[1] !== Infinity ? domain[1].toString() : "", ]); }, [domain]); - // Generic function for handling property changes (except for 'domain') + // Generic function for handling property changes (except for 'domain/range') function handlePropChange(property: string, newValue: string) { const updatedProps: Partial = {}; updatedProps[property] = newValue; @@ -71,8 +78,8 @@ const LockedFunctionSettings = (props: Props) => { } /* - Reason for having a separate 'propChange' function for 'domain': - Domain entries are optional. Their default value is +/- Infinity. + Reason for having a separate 'propChange' function for 'domain/range': + Domain/Range entries are optional. Their default value is +/- Infinity. Since input fields that are empty evaluate to zero, there needs to be dedicated code to convert empty to Infinity. */ @@ -94,6 +101,12 @@ const LockedFunctionSettings = (props: Props) => { onChangeProps({domain: newDomain}); } + const exampleCategories = Object.keys(examples); + const exampleCategorySelected = exampleCategory !== ""; + const exampleContent = exampleCategorySelected + ? examples[exampleCategory] + : ["Select category to see example equations"]; + return ( { handlePropChange("directionalAxis", newValue); }} aria-label="equation prefix" - style={styles.equationPrefix} + style={[styles.dropdownLabel, styles.axisMenu]} // Placeholder is required, but never gets used. placeholder="" > @@ -155,10 +168,13 @@ const LockedFunctionSettings = (props: Props) => { /> - {/* Domain restrictions */} + {/* Domain/Range restrictions */} - - {"domain min"} + + {`${domainRangeText} min`} { {"max"} @@ -190,6 +206,46 @@ const LockedFunctionSettings = (props: Props) => { + {/* Examples */} + Example Functions} + expanded={false} + containerStyle={styles.exampleWorkspace} + panelStyle={styles.exampleAccordionPanel} + > + + {"Choose a category"} + + + {exampleCategories.map((category) => { + return ( + + ); + })} + + + {exampleCategorySelected && ( +
    + {exampleContent.map((example, index) => ( + + ))} +
+ )} +
+ {/* Actions */} { ); }; +type ItemProps = { + category: string; + example: string; + index: number; + pasteEquationFn: (property: string, newValue: string) => void; +}; + +const ExampleItem = (props: ItemProps): React.ReactElement => { + const {category, example, index, pasteEquationFn} = props; + const exampleId = useId(); + + return ( +
  • + pasteEquationFn("equation", example)} + size="medium" + style={styles.copyPasteButton} + /> + navigator.clipboard.writeText(example)} + size="medium" + style={styles.copyPasteButton} + /> + + + {example} + +
  • + ); +}; + const styles = StyleSheet.create({ accordionHeader: { textOverflow: "ellipsis", @@ -210,17 +303,20 @@ const styles = StyleSheet.create({ overflow: "hidden", whiteSpace: "nowrap", }, - equationPrefix: { + axisMenu: { minWidth: "auto", }, + copyPasteButton: { + flexShrink: "0", + margin: "0 2px", + }, domainMin: { - alignItems: "center", - display: "flex", + justifyContent: "space-between", // The 'width' property is applied to the label, which wraps the text and the input field. // The width of the input fields (min/max) should be the same (to have a consistent look), // so the following calculation distributes the space accordingly. - // For the "domain min" block, the text is 82.7px, and the strut is 6px (88.7px total). - // The "domain max" block is 30.23px, and the strut is 6px (36.23px total). + // For the "domain/range min" block, the text is 82.7px, and the strut is 6px (88.7px total). + // The "domain/range max" block is 30.23px, and the strut is 6px (36.23px total). // The calculation takes the remain space after the text & struts (141px total) are removed, // and divides it between the two input fields equally. // The calculation reads: "Take 1/2 of the non-text space, and add the required space for this label's text" @@ -232,14 +328,50 @@ const styles = StyleSheet.create({ width: "calc(100% - 88.7px)", // make room for the label }, domainMax: { - alignItems: "center", - display: "flex", // See explanation for "domainMin" for the calculation below. width: "calc(((100% - 141px) / 2) + 36.2px)", }, domainMaxField: { width: "calc(100% - 36.2px)", // make room for the label }, + dropdownLabel: { + alignItems: "center", + display: "flex", + }, + exampleAccordionPanel: { + alignItems: "start", + paddingBottom: "12px", + flexDirection: "row", + flexWrap: "wrap", + }, + exampleContainer: { + background: "white", + border: `1px solid ${color.fadedOffBlack16}`, + borderRadius: "4px", + flexGrow: "1", + listStyleType: "none", + // Nothing special about the maxHeight value, + // just a good height to partially show a 3rd example in the list + // to hint at scrollable content. + maxHeight: "88px", + margin: "8px 0 0 0", + overflowY: "scroll", + padding: "4px 12px 4px 4px", + }, + exampleContent: { + fontFamily: `"Lato", sans-serif`, + flexGrow: "1", + color: color.offBlack, + }, + exampleRow: { + alignItems: "center", + display: "flex", + flexDirection: "row", + minHeight: "44px", + }, + exampleWorkspace: { + background: color.white50, + }, rowSpace: { marginTop: spacing.xSmall_8, },