From 2d5d63d2817e93fac50b644908d24d1929f14119 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 16 Jan 2025 13:10:55 -0800 Subject: [PATCH] Move Numeric Input's UI related code to a functional component (#2108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: We have a lot of old patterns in Perseus, and we would like to start to updating to more modern methods. This PR updates the Numeric Input logic in the following ways: 1. Moves all UI Related Numeric Input logic to a functional component, and creates a new `numeric-input.class.tsx` file for housing the static / class methods. 2. Removes all string refs related to Numeric Input in both the InputWithExamples, SimpleKeypadInput, and Tooltip components 3. Adds a little more specificity to method parameters Issue: LEMS-2443 ## Test plan: - Manual Testing - Automated Tests - Landing onto a feature branch for future QA Regression pass Author: SonicScrewdriver Reviewers: SonicScrewdriver, mark-fitzgerald Required Reviewers: Approved By: mark-fitzgerald Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2108 --- .changeset/smart-countries-hunt.md | 6 + .../src/components/input/math-input.tsx | 3 + .../src/components/input-with-examples.tsx | 17 +- .../src/components/simple-keypad-input.tsx | 25 +- packages/perseus/src/components/tooltip.tsx | 2 - .../numeric-input/prompt-utils.ts | 2 +- .../graded-group-set-jipt.test.ts.snap | 6 +- .../graded-group-set.test.ts.snap | 2 +- .../group/__snapshots__/group.test.tsx.snap | 4 +- .../__snapshots__/numeric-input.test.ts.snap | 46 +- .../src/widgets/numeric-input/index.ts | 2 +- .../numeric-input/numeric-input.class.tsx | 276 ++++++++++ .../numeric-input/numeric-input.stories.tsx | 2 +- .../numeric-input/numeric-input.test.ts | 4 +- .../widgets/numeric-input/numeric-input.tsx | 480 ++++-------------- .../src/widgets/numeric-input/utils.test.ts | 84 +++ .../src/widgets/numeric-input/utils.ts | 71 +++ 17 files changed, 592 insertions(+), 440 deletions(-) create mode 100644 .changeset/smart-countries-hunt.md create mode 100644 packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx create mode 100644 packages/perseus/src/widgets/numeric-input/utils.test.ts create mode 100644 packages/perseus/src/widgets/numeric-input/utils.ts diff --git a/.changeset/smart-countries-hunt.md b/.changeset/smart-countries-hunt.md new file mode 100644 index 0000000000..fcb293c3fc --- /dev/null +++ b/.changeset/smart-countries-hunt.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/math-input": minor +"@khanacademy/perseus": minor +--- + +Refactoring Numeric Input to move UI-logic to functional component. diff --git a/packages/math-input/src/components/input/math-input.tsx b/packages/math-input/src/components/input/math-input.tsx index 2a9f5cfb52..496cade97f 100644 --- a/packages/math-input/src/components/input/math-input.tsx +++ b/packages/math-input/src/components/input/math-input.tsx @@ -353,6 +353,9 @@ class MathInput extends React.Component { }); }; + // [Jan 2025] Third: While testing, I've discovered that we likely don't + // need to be passing setKeypadActive here at all. Removing the parameter + // still results in the same behavior. focus: (setKeypadActive: KeypadContextType["setKeypadActive"]) => void = ( setKeypadActive, ) => { diff --git a/packages/perseus/src/components/input-with-examples.tsx b/packages/perseus/src/components/input-with-examples.tsx index 1b8b70645e..6a8a575851 100644 --- a/packages/perseus/src/components/input-with-examples.tsx +++ b/packages/perseus/src/components/input-with-examples.tsx @@ -51,6 +51,8 @@ class InputWithExamples extends React.Component { static contextType = PerseusI18nContext; declare context: React.ContextType; + inputRef: React.RefObject; + static defaultProps: DefaultProps = { shouldShowExamples: true, onFocus: function () {}, @@ -65,6 +67,11 @@ class InputWithExamples extends React.Component { showExamples: false, }; + constructor(props: Props) { + super(props); + this.inputRef = React.createRef(); + } + _getUniqueId: () => string = () => { return `input-with-examples-${btoa(this.props.id).replace(/=/g, "")}`; }; @@ -98,7 +105,7 @@ class InputWithExamples extends React.Component { const inputProps = { id: id, "aria-describedby": ariaId, - ref: "input", + ref: this.inputRef, className: this._getInputClassName(), labelText: this.props.labelText, value: this.props.value, @@ -148,15 +155,11 @@ class InputWithExamples extends React.Component { }; focus: () => void = () => { - // eslint-disable-next-line react/no-string-refs - // @ts-expect-error - TS2339 - Property 'focus' does not exist on type 'ReactInstance'. - this.refs.input.focus(); + this.inputRef.current?.focus(); }; blur: () => void = () => { - // eslint-disable-next-line react/no-string-refs - // @ts-expect-error - TS2339 - Property 'blur' does not exist on type 'ReactInstance'. - this.refs.input.blur(); + this.inputRef.current?.blur(); }; handleChange: (arg1: any) => void = (e) => { diff --git a/packages/perseus/src/components/simple-keypad-input.tsx b/packages/perseus/src/components/simple-keypad-input.tsx index 0889a92934..f1cb199cc6 100644 --- a/packages/perseus/src/components/simple-keypad-input.tsx +++ b/packages/perseus/src/components/simple-keypad-input.tsx @@ -8,6 +8,7 @@ * interface to `math-input`'s MathInput component. */ +import {KeypadContext} from "@khanacademy/keypad-context"; import { KeypadInput, KeypadType, @@ -17,7 +18,15 @@ import PropTypes from "prop-types"; import * as React from "react"; export default class SimpleKeypadInput extends React.Component { + static contextType = KeypadContext; + declare context: React.ContextType; _isMounted = false; + inputRef: React.RefObject; + + constructor(props: any) { + super(props); + this.inputRef = React.createRef(); + } componentDidMount() { // TODO(scottgrant): This is a hack to remove the deprecated call to @@ -30,18 +39,13 @@ export default class SimpleKeypadInput extends React.Component { } focus() { - // @ts-expect-error - TS2339 - Property 'focus' does not exist on type 'ReactInstance'. - this.refs.input.focus(); // eslint-disable-line react/no-string-refs + // The inputRef is a ref to a MathInput, which + // also controls the keypad state during focus events. + this.inputRef.current?.focus(this.context.setKeypadActive); } blur() { - // eslint-disable-next-line react/no-string-refs - // @ts-expect-error - TS2339 - Property 'blur' does not exist on type 'ReactInstance'. - if (typeof this.refs.input?.blur === "function") { - // eslint-disable-next-line react/no-string-refs - // @ts-expect-error - TS2339 - Property 'blur' does not exist on type 'ReactInstance'. - this.refs.input?.blur(); - } + this.inputRef.current?.blur(); } getValue(): string | number { @@ -59,8 +63,7 @@ export default class SimpleKeypadInput extends React.Component { return ( // @ts-expect-error - TS2769 - No overload matches this call. { if (keypadElement) { diff --git a/packages/perseus/src/components/tooltip.tsx b/packages/perseus/src/components/tooltip.tsx index 21756dcf70..a112ce586c 100644 --- a/packages/perseus/src/components/tooltip.tsx +++ b/packages/perseus/src/components/tooltip.tsx @@ -356,8 +356,6 @@ class Tooltip extends React.Component { {/* The contents of the tooltip */}
-
+
- + + + -
@@ -100,7 +102,7 @@ exports[`numeric-input widget Should render predictably: after interaction 1`] = autocapitalize="off" autocomplete="off" autocorrect="off" - class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-input_ylhnsi" + class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-inputWithExamples_ylhnsi" id="input-with-examples-bnVtZXJpYy1pbnB1dCAx" type="text" value="1252" @@ -317,7 +319,7 @@ exports[`numeric-input widget Should render predictably: first render 1`] = ` autocapitalize="off" autocomplete="off" autocorrect="off" - class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-input_1y6ajxo" + class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-inputWithExamples_1y6ajxo" id="input-with-examples-bnVtZXJpYy1pbnB1dCAx" type="text" value="" @@ -534,7 +536,7 @@ exports[`numeric-input widget Should render tooltip as list when multiple format autocapitalize="off" autocomplete="off" autocorrect="off" - class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-input_1y6ajxo" + class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-inputWithExamples_1y6ajxo" id="input-with-examples-bnVtZXJpYy1pbnB1dCAx" type="text" value="" @@ -698,7 +700,7 @@ exports[`numeric-input widget Should render tooltip when format option is given: autocapitalize="off" autocomplete="off" autocorrect="off" - class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-input_1y6ajxo" + class="input_1ck1z8k-o_O-LabelMedium_1rew30o-o_O-default_53h0n9-o_O-defaultFocus_9n1kv3-o_O-inputWithExamples_1y6ajxo" id="input-with-examples-bnVtZXJpYy1pbnB1dCAx" type="text" value="" diff --git a/packages/perseus/src/widgets/numeric-input/index.ts b/packages/perseus/src/widgets/numeric-input/index.ts index b4020aa5c3..4297e3379b 100644 --- a/packages/perseus/src/widgets/numeric-input/index.ts +++ b/packages/perseus/src/widgets/numeric-input/index.ts @@ -1 +1 @@ -export {default} from "./numeric-input"; +export {default} from "./numeric-input.class"; diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx new file mode 100644 index 0000000000..8164914c53 --- /dev/null +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx @@ -0,0 +1,276 @@ +import {KhanMath} from "@khanacademy/kmath"; +import {linterContextDefault} from "@khanacademy/perseus-linter"; +import * as React from "react"; +import _ from "underscore"; + +import {ApiOptions} from "../../perseus-api"; +import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/numeric-input/prompt-utils"; + +import {NumericInputComponent} from "./numeric-input"; +import scoreNumericInput from "./score-numeric-input"; +import {NumericExampleStrings} from "./utils"; + +import type InputWithExamples from "../../components/input-with-examples"; +import type SimpleKeypadInput from "../../components/simple-keypad-input"; +import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; +import type { + PerseusNumericInputRubric, + PerseusNumericInputUserInput, +} from "../../validation.types"; +import type {NumericInputPromptJSON} from "../../widget-ai-utils/numeric-input/prompt-utils"; +import type { + PerseusNumericInputWidgetOptions, + PerseusNumericInputAnswerForm, + MathFormat, +} from "@khanacademy/perseus-core"; +import type {PropsFor} from "@khanacademy/wonder-blocks-core"; +import type {RefObject} from "react"; + +type ExternalProps = WidgetProps< + PerseusNumericInputWidgetOptions, + PerseusNumericInputRubric +>; + +export type NumericInputProps = ExternalProps & { + size: NonNullable; + rightAlign: NonNullable; + apiOptions: NonNullable; + coefficient: NonNullable; + answerForms: NonNullable; + labelText: string; + linterContext: NonNullable; + currentValue: string; +}; + +type DefaultProps = { + currentValue: NumericInputProps["currentValue"]; + size: NumericInputProps["size"]; + rightAlign: NumericInputProps["rightAlign"]; + apiOptions: NumericInputProps["apiOptions"]; + coefficient: NumericInputProps["coefficient"]; + answerForms: NumericInputProps["answerForms"]; + labelText: NumericInputProps["labelText"]; + linterContext: NumericInputProps["linterContext"]; +}; + +// Assert that the PerseusNumericInputWidgetOptions parsed from JSON can be passed +// as props to this component. This ensures that the PerseusMatrixWidgetOptions +// stays in sync with the prop types. The PropsFor type takes +// defaultProps into account, which is important because +// PerseusNumericInputWidgetOptions has optional fields which receive defaults +// via defaultProps. +0 as any as WidgetProps< + PerseusNumericInputWidgetOptions, + PerseusNumericInputRubric +> satisfies PropsFor; + +/** + * The NumericInput widget is a numeric input field that supports a variety of + * answer forms, including integers, decimals, fractions, and mixed numbers. + * + * [Jan 2025] We're currenly migrating from class-based components to functional + * components. While we cannot fully migrate this component yet, we can start + * by using the functional component for the rendering the UI of the widget. + */ +export class NumericInput + extends React.Component + implements Widget +{ + inputRef: RefObject; + + static defaultProps: DefaultProps = { + currentValue: "", + size: "normal", + rightAlign: false, + apiOptions: ApiOptions.defaults, + coefficient: false, + answerForms: [], + labelText: "", + linterContext: linterContextDefault, + }; + + static getUserInputFromProps( + props: NumericInputProps, + ): PerseusNumericInputUserInput { + return { + currentValue: props.currentValue, + }; + } + + constructor(props: NumericInputProps) { + super(props); + // Create a ref that we can pass down to the input component so that we + // can call focus on it when necessary. + this.inputRef = React.createRef< + SimpleKeypadInput | InputWithExamples + >(); + } + + focus: () => boolean = () => { + this.inputRef.current?.focus(); + return true; + }; + + focusInputPath: () => void = () => { + this.inputRef.current?.focus(); + }; + + blurInputPath: () => void = () => { + this.inputRef.current?.blur(); + }; + + getInputPaths: () => ReadonlyArray> = () => { + // The widget itself is an input, so we return a single empty list to + // indicate this. + /* c8 ignore next */ + return [[]]; + }; + + /** + * Sets the value of the input at the given path. + */ + setInputValue: ( + path: FocusPath, + newValue: string, + cb?: () => unknown | null | undefined, + ) => void = (path, newValue, cb) => { + this.props.onChange({currentValue: newValue}, cb); + }; + + /** + * Returns the value the user has currently input for this widget. + */ + getUserInput(): PerseusNumericInputUserInput { + return NumericInput.getUserInputFromProps(this.props); + } + + /** + * Returns the JSON representation of the prompt for this widget. + * This is used by the AI to determine the prompt for the widget. + */ + getPromptJSON(): NumericInputPromptJSON { + return _getPromptJSON(this.props, this.getUserInput()); + } + + render(): React.ReactNode { + return ; + } +} + +// TODO(thomas): Currently we receive a list of lists of acceptable answer types +// and union them down into a single set. It's worth considering whether it +// wouldn't make more sense to have a single set of acceptable answer types for +// a given *problem* rather than for each possible [correct/wrong] *answer*. +// When should two answers to a problem take different answer types? +// See D27790 for more discussion. +export const unionAnswerForms: ( + answerFormsList: ReadonlyArray< + ReadonlyArray + >, +) => ReadonlyArray = function (answerFormsList) { + // Takes a list of lists of answer forms, and returns a list of the forms + // in each of these lists in the same order that they're listed in the + // `formExamples` forms from above. + + // uniqueBy takes a list of elements and a function which compares whether + // two elements are equal, and returns a list of unique elements. This is + // just a helper function here, but works generally. + const uniqueBy = function (list, iteratee: any) { + // @ts-expect-error - TS2347 - Untyped function calls may not accept type arguments. + return list.reduce>((uniqueList, element) => { + // For each element, decide whether it's already in the list of + // unique items. + const inList = _.find(uniqueList, iteratee.bind(null, element)); + if (inList) { + return uniqueList; + } + return uniqueList.concat([element]); + }, []); + }; + + // Pull out all of the forms from the different lists. + const allForms = answerFormsList.flat(); + // Pull out the unique forms using uniqueBy. + const uniqueForms = uniqueBy(allForms, _.isEqual); + // Sort them by the order they appear in the `formExamples` list. + const formExampleKeys = Object.keys(NumericExampleStrings); + return _.sortBy(uniqueForms, (form) => { + return formExampleKeys.indexOf(form.name); + }); +}; + +type RenderProps = { + answerForms: ReadonlyArray<{ + simplify: "required" | "correct" | "enforced" | null | undefined; + name: "integer" | "decimal" | "proper" | "improper" | "mixed" | "pi"; + }>; + labelText: string; + size: "normal" | "small"; + coefficient: boolean; + rightAlign?: boolean; + static: boolean; +}; + +const propsTransform = function ( + widgetOptions: PerseusNumericInputWidgetOptions, +): RenderProps { + const rendererProps = _.extend(_.omit(widgetOptions, "answers"), { + answerForms: unionAnswerForms( + // Pull out the name of each form and whether that form has + // required simplification. + widgetOptions.answers.map((answer) => { + // @ts-expect-error - TS2345 - Argument of type 'readonly MathFormat[] | undefined' is not assignable to parameter of type 'Collection'. + return _.map(answer.answerForms, (form) => { + return { + simplify: answer.simplify, + name: form, + }; + }); + }), + ), + }); + + return rendererProps; +}; + +export default { + name: "numeric-input", + displayName: "Numeric input", + defaultAlignment: "inline-block", + accessible: true, + widget: NumericInput, + transform: propsTransform, + isLintable: true, + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusNumericInputUserInput'. + scorer: scoreNumericInput, + + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: Type 'Rubric' is not assignable to type 'PerseusNumericInputRubric' + getOneCorrectAnswerFromRubric( + rubric: PerseusNumericInputRubric, + ): string | null | undefined { + const correctAnswers = rubric.answers.filter( + (answer) => answer.status === "correct", + ); + const answerStrings = correctAnswers.map((answer) => { + // Either get the first answer form or default to decimal + const format: MathFormat = + answer.answerForms && answer.answerForms[0] + ? answer.answerForms[0] + : "decimal"; + + let answerString = KhanMath.toNumericString(answer.value!, format); + if (answer.maxError) { + answerString += + " \u00B1 " + + KhanMath.toNumericString(answer.maxError, format); + } + return answerString; + }); + if (answerStrings.length === 0) { + return; + } + return answerStrings[0]; + }, +} satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.stories.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.stories.tsx index ec1678b1cf..0222b37690 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.stories.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.stories.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import {RendererWithDebugUI} from "../../../../../testing/renderer-with-debug-ui"; -import {NumericInput} from "./numeric-input"; +import {NumericInput} from "./numeric-input.class"; import {question1} from "./numeric-input.testdata"; type StoryArgs = { diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts b/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts index d177fdb34e..38fd36cc99 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.test.ts @@ -6,7 +6,9 @@ import * as Dependencies from "../../dependencies"; import {scorePerseusItemTesting} from "../../util/test-utils"; import {renderQuestion} from "../__testutils__/renderQuestion"; -import NumericInputWidgetExport, {unionAnswerForms} from "./numeric-input"; +import NumericInputWidgetExport, { + unionAnswerForms, +} from "./numeric-input.class"; import { question1AndAnswer, multipleAnswers, diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx index 24af99bea4..4fbffacf19 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx @@ -1,419 +1,123 @@ -import {KhanMath} from "@khanacademy/kmath"; -import {linterContextDefault} from "@khanacademy/perseus-linter"; import {StyleSheet} from "aphrodite"; import * as React from "react"; +import { + forwardRef, + useContext, + useImperativeHandle, + useRef, + useState, +} from "react"; import _ from "underscore"; import {PerseusI18nContext} from "../../components/i18n-context"; import InputWithExamples from "../../components/input-with-examples"; import SimpleKeypadInput from "../../components/simple-keypad-input"; -import {ApiOptions} from "../../perseus-api"; -import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/numeric-input/prompt-utils"; -import scoreNumericInput from "./score-numeric-input"; - -import type {PerseusStrings} from "../../strings"; -import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; -import type { - PerseusNumericInputRubric, - PerseusNumericInputUserInput, -} from "../../validation.types"; -import type {NumericInputPromptJSON} from "../../widget-ai-utils/numeric-input/prompt-utils"; -import type { - PerseusNumericInputWidgetOptions, - PerseusNumericInputAnswerForm, -} from "@khanacademy/perseus-core"; -import type {PropsFor} from "@khanacademy/wonder-blocks-core"; - -const formExamples: { - [key: string]: ( - arg1: PerseusNumericInputAnswerForm, - strings: PerseusStrings, - ) => string; -} = { - integer: (form, strings: PerseusStrings) => strings.integerExample, - proper: (form, strings: PerseusStrings) => - form.simplify === "optional" - ? strings.properExample - : strings.simplifiedProperExample, - improper: (form, strings: PerseusStrings) => - form.simplify === "optional" - ? strings.improperExample - : strings.simplifiedImproperExample, - mixed: (form, strings: PerseusStrings) => strings.mixedExample, - decimal: (form, strings: PerseusStrings) => strings.decimalExample, - pi: (form, strings: PerseusStrings) => strings.piExample, -}; - -type ExternalProps = WidgetProps< - PerseusNumericInputWidgetOptions, - PerseusNumericInputRubric ->; - -type Props = ExternalProps & { - size: NonNullable; - rightAlign: NonNullable; - apiOptions: NonNullable; - coefficient: NonNullable; - answerForms: NonNullable; - labelText: string; - linterContext: NonNullable; - currentValue: string; -}; - -type DefaultProps = { - currentValue: Props["currentValue"]; - size: Props["size"]; - rightAlign: Props["rightAlign"]; - apiOptions: Props["apiOptions"]; - coefficient: Props["coefficient"]; - answerForms: Props["answerForms"]; - labelText: Props["labelText"]; - linterContext: Props["linterContext"]; -}; - -// Assert that the PerseusNumericInputWidgetOptions parsed from JSON can be passed -// as props to this component. This ensures that the PerseusMatrixWidgetOptions -// stays in sync with the prop types. The PropsFor type takes -// defaultProps into account, which is important because -// PerseusNumericInputWidgetOptions has optional fields which receive defaults -// via defaultProps. -0 as any as WidgetProps< - PerseusNumericInputWidgetOptions, - PerseusNumericInputRubric -> satisfies PropsFor; - -type State = { - // keeps track of the other set of values when switching - // between 0 and finite solutions - previousValues: ReadonlyArray; - isFocused: boolean; -}; - -export class NumericInput - extends React.Component - implements Widget -{ - static contextType = PerseusI18nContext; - declare context: React.ContextType; - - inputRef: SimpleKeypadInput | InputWithExamples | null | undefined; - - static defaultProps: DefaultProps = { - currentValue: "", - size: "normal", - rightAlign: false, - apiOptions: ApiOptions.defaults, - coefficient: false, - answerForms: [], - labelText: "", - linterContext: linterContextDefault, - }; - - static getUserInputFromProps(props: Props): PerseusNumericInputUserInput { - return { - currentValue: props.currentValue, - }; - } - - state: State = { - // keeps track of the other set of values when switching - // between 0 and finite solutions - previousValues: [""], - isFocused: false, - }; - - /** - * Generates a string that demonstrates how to input the various supported - * answer forms. - */ - examples(): ReadonlyArray { - // if the set of specified forms are empty, allow all forms - const forms = - this.props.answerForms?.length !== 0 - ? this.props.answerForms - : Object.keys(formExamples).map((name) => { - return { - name: name, - simplify: "required", - } as PerseusNumericInputAnswerForm; - }); - - let examples = _.map(forms, (form) => { - return formExamples[form.name](form, this.context.strings); - }); - // Ensure no duplicate tooltip text from simplified and unsimplified - // versions of the same format - examples = _.uniq(examples); - - return [this.context.strings.yourAnswer].concat(examples); - } - - shouldShowExamples: () => boolean = () => { - const noFormsAccepted = this.props.answerForms?.length === 0; - // To check if all answer forms are accepted, we must first - // find the *names* of all accepted forms, and see if they are - // all present, ignoring duplicates - const answerFormNames: ReadonlyArray = _.uniq( - this.props.answerForms?.map((form) => form.name), - ); - const allFormsAccepted = - answerFormNames.length >= Object.keys(formExamples).length; - return !noFormsAccepted && !allFormsAccepted; - }; - - focus: () => boolean = () => { - this.inputRef?.focus(); - return true; - }; - - focusInputPath: () => void = () => { - this.inputRef?.focus(); - }; - - blurInputPath: () => void = () => { - this.inputRef?.blur(); - }; - - getInputPaths: () => ReadonlyArray> = () => { - // The widget itself is an input, so we return a single empty list to - // indicate this. - /* c8 ignore next */ - return [[]]; - }; - - setInputValue: ( - arg1: FocusPath, - arg2: string, - arg3?: () => unknown | null | undefined, - ) => void = (path, newValue, cb) => { - /* c8 ignore next */ - this.props.onChange( - { - currentValue: newValue, +import {type NumericInputProps} from "./numeric-input.class"; +import {generateExamples, shouldShowExamples} from "./utils"; + +type InputRefType = SimpleKeypadInput | InputWithExamples | null; + +/** + * The NumericInputComponent is a child component of the NumericInput class + * component. It is responsible for rendering the UI elements of the Numeric + * Input widget. + */ +export const NumericInputComponent = forwardRef( + (props: NumericInputProps, ref) => { + const context = useContext(PerseusI18nContext); + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + // Pass the focus and blur methods to the Numeric Input Class component + useImperativeHandle(ref, () => ({ + focus: () => { + if (inputRef.current) { + inputRef.current.focus(); + setIsFocused(true); + } }, - cb, - ); - }; - - getUserInput(): PerseusNumericInputUserInput { - return NumericInput.getUserInputFromProps(this.props); - } - - getPromptJSON(): NumericInputPromptJSON { - return _getPromptJSON(this.props, this.getUserInput()); - } - - handleChange: ( - arg1: string, - arg2?: () => unknown | null | undefined, - ) => void = (newValue, cb) => { - this.props.onChange({currentValue: newValue}, cb); - this.props.trackInteraction(); - }; - - _handleFocus: () => void = () => { - this.props.onFocus([]); - this.setState((currentState) => { - return {...currentState, isFocused: true}; - }); - }; - - _handleBlur: () => void = () => { - this.props.onBlur([]); - this.setState((currentState) => { - return {...currentState, isFocused: false}; - }); - }; + blur: () => { + if (inputRef.current) { + inputRef.current.blur(); + setIsFocused(false); + } + }, + })); + + const handleChange = ( + newValue: string, + cb?: () => unknown | null | undefined, + ): void => { + props.onChange({currentValue: newValue}, cb); + props.trackInteraction(); + }; - render(): React.ReactNode { - let labelText = this.props.labelText; - if (labelText == null || labelText === "") { - labelText = this.context.strings.yourAnswerLabel; - } + const handleFocus = (): void => { + props.onFocus([]); + setIsFocused(true); + }; - // To right align a custom keypad we need to wrap it. - const maybeRightAlignKeypadInput = ( - keypadInput: React.ReactElement< - React.ComponentProps - >, - ) => { - return this.props.rightAlign ? ( -
{keypadInput}
- ) : ( - keypadInput - ); + const handleBlur = (): void => { + props.onBlur([]); + setIsFocused(false); }; - if (this.props.apiOptions.customKeypad) { - // TODO(charlie): Support "Review Mode". - return maybeRightAlignKeypadInput( - (this.inputRef = ref)} - value={this.props.currentValue} - keypadElement={this.props.keypadElement} - onChange={this.handleChange} - onFocus={this._handleFocus} - onBlur={this._handleBlur} - />, - ); + // If the labelText is not provided by the Content Creators, use the default label text + let labelText = props.labelText; + if (labelText == null || labelText === "") { + labelText = context.strings.yourAnswerLabel; } - // Note: This is _very_ similar to what `input-number.jsx` does. If - // you modify this, double-check if you also need to modify that - // component. + // Styles for the InputWithExamples const styles = StyleSheet.create({ - input: { + inputWithExamples: { borderRadius: "3px", - borderWidth: this.state.isFocused ? "2px" : "1px", + borderWidth: isFocused ? "2px" : "1px", display: "inline-block", fontFamily: `Symbola, "Times New Roman", serif`, fontSize: "18px", height: "32px", lineHeight: "18px", - padding: this.state.isFocused ? "4px" : "4px 5px", // account for added focus border thickness - textAlign: this.props.rightAlign ? "right" : "left", - width: this.props.size === "small" ? 40 : 80, + padding: isFocused ? "4px" : "4px 5px", + textAlign: props.rightAlign ? "right" : "left", + width: props.size === "small" ? 40 : 80, }, }); + // (mobile-only) If the custom keypad is enabled, use the SimpleKeypadInput component + if (props.apiOptions.customKeypad) { + const alignmentClass = props.rightAlign + ? "perseus-input-right-align" + : undefined; + return ( +
+ } + value={props.currentValue} + keypadElement={props.keypadElement} + onChange={handleChange} + onFocus={handleFocus} + onBlur={handleBlur} + /> +
+ ); + } + // (desktop-only) Otherwise, use the InputWithExamples component return ( (this.inputRef = ref)} - value={this.props.currentValue} - onChange={this.handleChange} + ref={inputRef as React.RefObject} + value={props.currentValue} + onChange={handleChange} labelText={labelText} - examples={this.examples()} - shouldShowExamples={this.shouldShowExamples()} - onFocus={this._handleFocus} - onBlur={this._handleBlur} - id={this.props.widgetId} - disabled={this.props.apiOptions.readOnly} - style={styles.input} + examples={generateExamples(props.answerForms, context.strings)} + shouldShowExamples={shouldShowExamples(props.answerForms)} + onFocus={handleFocus} + onBlur={handleBlur} + id={props.widgetId} + disabled={props.apiOptions.readOnly} + style={styles.inputWithExamples} /> ); - } -} - -// TODO(thomas): Currently we receive a list of lists of acceptable answer types -// and union them down into a single set. It's worth considering whether it -// wouldn't make more sense to have a single set of acceptable answer types for -// a given *problem* rather than for each possible [correct/wrong] *answer*. -// When should two answers to a problem take different answer types? -// See D27790 for more discussion. -export const unionAnswerForms: ( - arg1: ReadonlyArray>, -) => ReadonlyArray = function (answerFormsList) { - // Takes a list of lists of answer forms, and returns a list of the forms - // in each of these lists in the same order that they're listed in the - // `formExamples` forms from above. - - // uniqueBy takes a list of elements and a function which compares whether - // two elements are equal, and returns a list of unique elements. This is - // just a helper function here, but works generally. - const uniqueBy = function (list, iteratee: any) { - // @ts-expect-error - TS2347 - Untyped function calls may not accept type arguments. - return list.reduce>((uniqueList, element) => { - // For each element, decide whether it's already in the list of - // unique items. - const inList = _.find(uniqueList, iteratee.bind(null, element)); - if (inList) { - return uniqueList; - } - return uniqueList.concat([element]); - }, []); - }; - - // Pull out all of the forms from the different lists. - const allForms = answerFormsList.flat(); - // Pull out the unique forms using uniqueBy. - const uniqueForms = uniqueBy(allForms, _.isEqual); - // Sort them by the order they appear in the `formExamples` list. - const formExampleKeys = Object.keys(formExamples); - return _.sortBy(uniqueForms, (form) => { - return formExampleKeys.indexOf(form.name); - }); -}; - -type RenderProps = { - answerForms: ReadonlyArray<{ - simplify: "required" | "correct" | "enforced" | null | undefined; - name: "integer" | "decimal" | "proper" | "improper" | "mixed" | "pi"; - }>; - labelText: string; - size: "normal" | "small"; - coefficient: boolean; - rightAlign?: boolean; - static: boolean; -}; - -const propsTransform = function ( - widgetOptions: PerseusNumericInputWidgetOptions, -): RenderProps { - const rendererProps = _.extend(_.omit(widgetOptions, "answers"), { - answerForms: unionAnswerForms( - // Pull out the name of each form and whether that form has - // required simplification. - widgetOptions.answers.map((answer) => { - // @ts-expect-error - TS2345 - Argument of type 'readonly MathFormat[] | undefined' is not assignable to parameter of type 'Collection'. - return _.map(answer.answerForms, (form) => { - return { - simplify: answer.simplify, - name: form, - }; - }); - }), - ), - }); - - return rendererProps; -}; - -export default { - name: "numeric-input", - displayName: "Numeric input", - defaultAlignment: "inline-block", - accessible: true, - widget: NumericInput, - transform: propsTransform, - isLintable: true, - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusNumericInputUserInput'. - scorer: scoreNumericInput, - - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: Type 'Rubric' is not assignable to type 'PerseusNumericInputRubric' - getOneCorrectAnswerFromRubric( - rubric: PerseusNumericInputRubric, - ): string | null | undefined { - const correctAnswers = rubric.answers.filter( - (answer) => answer.status === "correct", - ); - const answerStrings = correctAnswers.map((answer) => { - // Figure out how this answer is supposed to be - // displayed - let format = "decimal"; - if (answer.answerForms && answer.answerForms[0]) { - // NOTE(johnsullivan): This isn't exactly ideal, but - // it does behave well for all the currently known - // problems. See D14742 for some discussion on - // alternate strategies. - format = answer.answerForms[0]; - } - - // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'MathFormat | undefined'. - let answerString = KhanMath.toNumericString(answer.value, format); - if (answer.maxError) { - answerString += - " \u00B1 " + - // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'MathFormat | undefined'. - KhanMath.toNumericString(answer.maxError, format); - } - return answerString; - }); - if (answerStrings.length === 0) { - return; - } - return answerStrings[0]; }, -} satisfies WidgetExports; +); diff --git a/packages/perseus/src/widgets/numeric-input/utils.test.ts b/packages/perseus/src/widgets/numeric-input/utils.test.ts new file mode 100644 index 0000000000..2e3dc3200a --- /dev/null +++ b/packages/perseus/src/widgets/numeric-input/utils.test.ts @@ -0,0 +1,84 @@ +import {generateExamples, shouldShowExamples} from "./utils"; + +import type {PerseusStrings} from "../../strings"; +import type {PerseusNumericInputAnswerForm} from "@khanacademy/perseus-core"; + +describe("generateExamples", () => { + it("returns an array of examples", () => { + const answerForms: readonly PerseusNumericInputAnswerForm[] = [ + { + name: "integer", + simplify: "optional", + }, + { + name: "proper", + simplify: "required", + }, + ]; + const strings: Partial = { + yourAnswer: "Your answer", + integerExample: "Integer example", + properExample: "Proper example", + simplifiedProperExample: "Simplified proper example", + }; + const expected = [ + "Your answer", + "Integer example", + "Simplified proper example", + ]; + expect( + generateExamples(answerForms, strings as PerseusStrings), + ).toEqual(expected); + }); +}); + +describe("shouldShowExamples", () => { + it("returns true when not all forms are accepted", () => { + const answerForms: readonly PerseusNumericInputAnswerForm[] = [ + { + name: "integer", + simplify: "optional", + }, + { + name: "proper", + simplify: "required", + }, + ]; + expect(shouldShowExamples(answerForms)).toBe(true); + }); + + it("returns false when all forms are accepted", () => { + const answerForms: readonly PerseusNumericInputAnswerForm[] = [ + { + name: "integer", + simplify: "optional", + }, + { + name: "proper", + simplify: "required", + }, + { + name: "improper", + simplify: "optional", + }, + { + name: "mixed", + simplify: "required", + }, + { + name: "decimal", + simplify: "optional", + }, + { + name: "pi", + simplify: "required", + }, + ]; + expect(shouldShowExamples(answerForms)).toBe(false); + }); + + it("returns false when no forms are accepted", () => { + const answerForms = []; + expect(shouldShowExamples(answerForms)).toBe(false); + }); +}); diff --git a/packages/perseus/src/widgets/numeric-input/utils.ts b/packages/perseus/src/widgets/numeric-input/utils.ts new file mode 100644 index 0000000000..dd4f13e580 --- /dev/null +++ b/packages/perseus/src/widgets/numeric-input/utils.ts @@ -0,0 +1,71 @@ +import _ from "underscore"; + +import type {PerseusStrings} from "../../strings"; +import type {PerseusNumericInputAnswerForm} from "@khanacademy/perseus-core"; + +/** + * The full list of available strings for the numeric input widget, + * based on whether the Content Creator has specified that the answer must be simplified. + */ +export const NumericExampleStrings: { + [key: string]: ( + form: PerseusNumericInputAnswerForm, + strings: PerseusStrings, + ) => string; +} = { + integer: (form, strings: PerseusStrings) => strings.integerExample, + proper: (form, strings: PerseusStrings) => + form.simplify === "optional" + ? strings.properExample + : strings.simplifiedProperExample, + improper: (form, strings: PerseusStrings) => + form.simplify === "optional" + ? strings.improperExample + : strings.simplifiedImproperExample, + mixed: (form, strings: PerseusStrings) => strings.mixedExample, + decimal: (form, strings: PerseusStrings) => strings.decimalExample, + pi: (form, strings: PerseusStrings) => strings.piExample, +}; + +/** + * Generates the specific set of examples for the current question. + * This string is shown as examples to the user in a tooltip. + */ +export const generateExamples = ( + answerForms: readonly PerseusNumericInputAnswerForm[], + strings: PerseusStrings, +): ReadonlyArray => { + const forms = + answerForms?.length !== 0 + ? answerForms + : Object.keys(NumericExampleStrings).map((name) => { + return { + name: name, + simplify: "required", + } as PerseusNumericInputAnswerForm; + }); + + let examples = _.map(forms, (form) => { + return NumericExampleStrings[form.name](form, strings); + }); + examples = _.uniq(examples); + + return [strings.yourAnswer].concat(examples); +}; + +/** + * Determines whether to show examples of how to input + * the various supported answer forms. We do not show examples + * if all forms are accepted or if no forms are accepted. + */ +export const shouldShowExamples = ( + answerForms: readonly PerseusNumericInputAnswerForm[], +): boolean => { + const noFormsAccepted = answerForms?.length === 0; + const answerFormNames: ReadonlyArray = _.uniq( + answerForms?.map((form) => form.name), + ); + const allFormsAccepted = + answerFormNames.length >= Object.keys(NumericExampleStrings).length; + return !noFormsAccepted && !allFormsAccepted; +};