diff --git a/packages/perseus-editor/src/__stories__/editor-page.stories.tsx b/packages/perseus-editor/src/__stories__/editor-page.stories.tsx index 33569fa99d..174f9f73ed 100644 --- a/packages/perseus-editor/src/__stories__/editor-page.stories.tsx +++ b/packages/perseus-editor/src/__stories__/editor-page.stories.tsx @@ -4,12 +4,50 @@ import {registerAllWidgetsAndEditorsForTesting} from "../util/register-all-widge import EditorPageWithStorybookPreview from "./editor-page-with-storybook-preview"; +import type {InputNumberWidget, PerseusRenderer} from "@khanacademy/perseus"; + registerAllWidgetsAndEditorsForTesting(); // SIDE_EFFECTY!!!! :cry: export default { title: "PerseusEditor/EditorPage", }; +const question1: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "input-number 1": { + version: { + major: 0, + minor: 0, + }, + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0.1, + inexact: false, + value: 0.5, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, +}; + export const Demo = (): React.ReactElement => { return ; }; + +// Used to test that Input Numbers are being automatically converted to Numeric Inputs +export const InputNumberDemo = (): React.ReactElement => { + return ; +}; diff --git a/packages/perseus-editor/src/editor-page.tsx b/packages/perseus-editor/src/editor-page.tsx index c5f026b83d..aa708f7087 100644 --- a/packages/perseus-editor/src/editor-page.tsx +++ b/packages/perseus-editor/src/editor-page.tsx @@ -6,6 +6,7 @@ import JsonEditor from "./components/json-editor"; import ViewportResizer from "./components/viewport-resizer"; import CombinedHintsEditor from "./hint-editor"; import ItemEditor from "./item-editor"; +import {convertDeprecatedWidgets} from "./util/modernize-widgets-utils"; import type { APIOptions, @@ -16,6 +17,7 @@ import type { ImageUploader, Version, PerseusItem, + PerseusRenderer, } from "@khanacademy/perseus"; import type {KEScore} from "@khanacademy/perseus-core"; @@ -58,6 +60,7 @@ type Props = { type State = { json: PerseusItem; + question: PerseusRenderer; gradeMessage: string; wasAnswered: boolean; highlightLint: boolean; @@ -84,15 +87,21 @@ class EditorPage extends React.Component { constructor(props: Props) { super(props); + // Convert any widgets that need to be converted to newer widget types + let convertedQuestionJson: PerseusRenderer = props.question; + if (props.question) { + convertedQuestionJson = convertDeprecatedWidgets(props.question); + } + + const json = { + ..._.pick(this.props, "answerArea", "hints", "itemDataVersion"), + question: convertedQuestionJson, + }; + this.state = { // @ts-expect-error - TS2322 - Type 'Pick & Readonly<{ children?: ReactNode; }>, "hints" | "question" | "answerArea" | "itemDataVersion">' is not assignable to type 'PerseusJson'. - json: _.pick( - this.props, - "question", - "answerArea", - "hints", - "itemDataVersion", - ), + json: json, + question: json.question, gradeMessage: "", wasAnswered: false, highlightLint: true, @@ -296,7 +305,7 @@ class EditorPage extends React.Component { { + it("should be able to convert a simple input number widget into numeric input", () => { + // Arrange + const input = inputNumberSimple; + const expected = numericInputSimple; + + // Act + const result = convertDeprecatedWidgets(input); + + // Assert + expect(result.content).toEqual(expected.content); + expect(result.widgets).toEqual(expected.widgets); + }); + + it("should be able to convert a nested input number widget", () => { + // This test has the inputNumber widget nested within a gradedGroup widget + + // Arrange + const input = inputNumberNested; + const expected = numericInputNested; + + // Act + const result = convertDeprecatedWidgets(input); + + // Assert + expect(result).toEqual(expected); + }); + + it("should be able to continue id numbering even with nested widgets", () => { + // This test has 2 pre-existing numericInput widgets, with one of them being nested + // within a graded group. As a result, the inputNumber widget should become "numeric-input 3". + + // Arrange + const input = inputNumberNestedWithNumeric; + const expected = numericInputNestedWithNumeric; + + // Act + const result = convertDeprecatedWidgets(input); + + // Assert + expect(result).toEqual(expected); + }); +}); diff --git a/packages/perseus-editor/src/util/modernize-widgets-utils.testdata.ts b/packages/perseus-editor/src/util/modernize-widgets-utils.testdata.ts new file mode 100644 index 0000000000..a54bfa2e5f --- /dev/null +++ b/packages/perseus-editor/src/util/modernize-widgets-utils.testdata.ts @@ -0,0 +1,323 @@ +import type { + InputNumberWidget, + PerseusRenderer, + NumericInputWidget, + PerseusWidgetsMap, +} from "@khanacademy/perseus"; + +export const inputNumberSimple: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 input-number 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0.1, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, +}; + +export const numericInputSimple: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 numeric-input 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0.1, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, +}; + +export const inputNumberNestedWithNumeric: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n[[\u2603 numeric-input 1]] \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.1, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: + "This is just a couple of cute lil' [[\u2603 numeric-input 2]]'s and [[\u2603 input-number 1]]'s.", + images: {}, + widgets: { + "numeric-input 2": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.2, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0.1, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; + +export const numericInputNestedWithNumeric: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n[[\u2603 numeric-input 1]] \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.1, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: + "This is just a couple of cute lil' [[\u2603 numeric-input 2]]'s and [[\u2603 numeric-input 3]]'s.", + images: {}, + widgets: { + "numeric-input 2": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.2, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + "numeric-input 3": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0.1, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; + +export const inputNumberNested: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: "This is just a cute lil' [[\u2603 input-number 1]].", + images: {}, + widgets: { + "input-number 1": { + type: "input-number", + graded: true, + alignment: "default", + options: { + maxError: 0.1, + inexact: false, + value: 0.3333333333333333, + simplify: "optional", + answerType: "rational", + size: "normal", + }, + } as InputNumberWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; + +export const numericInputNested: PerseusRenderer = { + content: + "Denis baked a peach pie and cut it into $3$ equal-sized pieces. Denis's dad eats $1$ section of the pie. \n\n**What fraction of the pie did Denis's dad eat?** \n![](https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png) \n[[\u2603 graded-group 1]] \n\n\n\n", + images: { + "https://ka-perseus-graphie.s3.amazonaws.com/74a2b7583a2c26ebfb3ad714e29867541253fc97.png": + { + width: 200, + height: 200, + }, + }, + widgets: { + "graded-group 1": { + type: "graded-group", + alignment: "default", + static: false, + graded: true, + options: { + title: "Group 1", + content: "This is just a cute lil' [[\u2603 numeric-input 1]].", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + static: false, + answers: [ + { + value: 0.3333333333333333, + status: "correct", + message: "", + simplify: "optional", + strict: true, + maxError: 0.1, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + rightAlign: false, + }, + } as NumericInputWidget, + }, + }, + } as PerseusWidgetsMap["graded-group 1"], + }, +}; diff --git a/packages/perseus-editor/src/util/modernize-widgets-utils.ts b/packages/perseus-editor/src/util/modernize-widgets-utils.ts new file mode 100644 index 0000000000..ec901df74d --- /dev/null +++ b/packages/perseus-editor/src/util/modernize-widgets-utils.ts @@ -0,0 +1,208 @@ +import type { + NumericInputWidget, + PerseusRenderer, + PerseusWidgetsMap, +} from "@khanacademy/perseus"; + +type WidgetNameMap = { + [oldKey: string]: string; +}; + +// This utils in this file are used to modernize our Perseus JSON structure, +// so that we can convert deprecated widgets to their modern equivalents when +// content creators use the Editor Page to update content containing these widgets. +// Currently, we're only converting input-number to numeric-input, +// but we can add more conversions here in the future. + +const inputNumberToNumericInput = (json: PerseusRenderer) => { + // First we need to create a map of the old input-number keys to the new numeric-input keys + // so that we can ensure we update the content and widgets accordingly + const nameMap = getInputNumberNameMap(json); + + // Then we can use this to update the JSON + const modernizedJson = convertInputNumberJson(json, nameMap); + return modernizedJson; +}; + +// We need to be able to run this code recursively in order to convert nested input-number widgets +const convertInputNumberJson = ( + json: PerseusRenderer, + nameMap: WidgetNameMap, +): PerseusRenderer => { + const updatedContent = convertInputNumberContent(json, nameMap); + const updatedWidgets = convertInputNumberWidgetOptions(json, nameMap); + const modernizedJson = { + ...json, + content: updatedContent, + widgets: updatedWidgets, + }; + + return modernizedJson; +}; + +// Convert the input-number refs in the content string of a PerseusRenderer object +const convertInputNumberContent = ( + json: PerseusRenderer, + nameMap: WidgetNameMap, +): string => { + let newContent = json.content; + // Loop through the nameMap and replace all the old keys with the new keys + for (const oldKey of Object.keys(nameMap)) { + const newKey = nameMap[oldKey]; + if (newKey) { + newContent = newContent.replace(oldKey, newKey); + } + } + + return newContent; +}; + +// Convert the input-number json in the widgets of a PerseusRenderer object +const convertInputNumberWidgetOptions = ( + json: PerseusRenderer, + nameMap: WidgetNameMap, +): PerseusWidgetsMap => { + const widgets: PerseusWidgetsMap = {...json.widgets}; + // The question.widgets is a dictionary map of widgets, so we need to loop through in order to convert input-number to numeric-input + // which can exist as both the key or as a value on widget.type. + for (const key of Object.keys(widgets)) { + // (First) Loop through the keys of the widgets dictionary + if (widgets[key].options.widgets) { + widgets[key].options = { + ...convertInputNumberJson(widgets[key].options, nameMap), + }; + } + // (Second) Check if the widget is an input-number + if (widgets[key].type === "input-number") { + let provideAnswerForm = true; + if ( + widgets[key].options.value !== "number" && + widgets[key].options.value !== "rational" + ) { + provideAnswerForm = false; + } + + // We need to update the answers prop to match the numeric-input widget format + const answers: any = [ + { + value: widgets[key].options.value, + simplify: widgets[key].options.simplify, + strict: widgets[key].options.inexact || true, + maxError: widgets[key].options.maxError || 0, + status: "correct", // Input-number only allows correct answers + message: "", + }, + ]; + + // Add the required answerForms if provided/applicable + if (provideAnswerForm) { + answers[0].answerForms = [...widgets[key].options.answerType]; + } + + // Update the options prop to match the numeric-input widget format + const upgradedWidgetOptions = { + answers, + size: widgets[key].options.size, + coefficient: false, // input-number doesn't have a coefficient prop + labelText: "", // input-number doesn't have a labelText prop + static: false, // static is always false for numeric-input + rightAlign: widgets[key].options.rightAlign || false, + }; + const upgradedWidget: NumericInputWidget = { + options: upgradedWidgetOptions, + type: "numeric-input", + }; + const newWidgetName = nameMap[key]; + // Create the new key entry + widgets[newWidgetName] = upgradedWidget; + + // We need to delete the old widget key + delete widgets[key]; + } + } + return widgets; +}; + +// Extract all of the content strings existing within a PerseusRenderer object +const getAllContentStrings = (json: any): string[] => { + const contentStrings: string[] = []; + + const extractContent = (obj: any) => { + for (const key in obj) { + if (key === "content" && typeof obj[key] === "string") { + contentStrings.push(obj[key]); + } else if (typeof obj[key] === "object" && obj[key] !== null) { + extractContent(obj[key]); + } + } + }; + + extractContent(json); + return contentStrings; +}; + +// Create a map of the old input-number keys to the new numeric-input keys +const getInputNumberNameMap = (json: PerseusRenderer): WidgetNameMap => { + const numericRegex = /\[\[\u2603 (numeric-input ([0-9]+))\]\]/g; + + // Get all the content strings within the json as some might be nested within widgets + const allContentStrings = getAllContentStrings(json); + + // Then we need to loop through these strings and find all the pre-existing numeric-input widgets. + let numericMatches: RegExpMatchArray[] = []; + for (const contentString of allContentStrings) { + numericMatches = [ + ...numericMatches, + ...[...contentString.matchAll(numericRegex)], + ]; + } + + // We then count through the result to determine the initial count + // we should use when creating the new numeric-input widgets + let currentNumericCount = 0; // initialize to 0 + if (numericMatches) { + for (const match of numericMatches) { + const id = parseInt(match[2], 10); + if (id > currentNumericCount) { + currentNumericCount = id; + } + } + } + + currentNumericCount++; // We want to start the new ids at either the next number, or at 1. + + // Loop through the content to get all the input-number widgets in the content + const nameMap: WidgetNameMap = {}; + const inputNumberRegex = /\[\[\u2603 (input-number ([0-9]+))\]\]/g; // this will be in the following format: + let inputNumberMatches: RegExpMatchArray[] = []; + + for (const contentString of allContentStrings) { + inputNumberMatches = [ + ...inputNumberMatches, + ...[...contentString.matchAll(inputNumberRegex)], + ]; + } + + // If we have found any input number widgets, we need to create a map of the old keys to the new keys + if (inputNumberMatches) { + for (const match of inputNumberMatches) { + const oldKey = match[1]; + const newKey = `numeric-input ${currentNumericCount}`; + nameMap[oldKey] = newKey; + currentNumericCount++; + } + } + return nameMap; +}; + +// Modernize the json content of a PerseusRenderer object +// by converting deprecated widgets to their modern equivalents +export const convertDeprecatedWidgets = ( + json: PerseusRenderer, +): PerseusRenderer => { + // Currently we're only converting input-number to numeric-input, + // But we can add more conversions here in the future + const modernJson = inputNumberToNumericInput(json); + + return modernJson; +}; diff --git a/packages/perseus-editor/src/widgets/numeric-input-editor.tsx b/packages/perseus-editor/src/widgets/numeric-input-editor.tsx index 6723c6e6c4..aca3deb184 100644 --- a/packages/perseus-editor/src/widgets/numeric-input-editor.tsx +++ b/packages/perseus-editor/src/widgets/numeric-input-editor.tsx @@ -121,55 +121,8 @@ class NumericInputEditor extends React.Component { lastStatus: "wrong", showOptions: _.map(this.props.answers, () => false), }; - const upgradedProps = this.propsUpgrade(this.props); // quickhack for input number - this.props.onChange({props, ...upgradedProps}); // upgrade the props } - // Temporary function to upgrade props from the deprecated - // InputNumber widget to the new NumericInput widget. This can be removed - // once the deprecated InputNumber widget is removed from our content. This should - // happen naturally as Content Editors re-save and publish their content. - // SIDE NOTE: Ooo interesting, I wonder if we could build a script to auto re-publish all content somehow. - propsUpgrade = (initialProps: any): PerseusNumericInputWidgetOptions => { - console.log(initialProps); - if (initialProps.value) { - console.log("Upgrading props for input-number"); - let provideAnswerForm = true; - if ( - initialProps.value !== "number" && - initialProps.value !== "rational" - ) { - provideAnswerForm = false; - } - - const answers = [ - { - value: initialProps.value, - simplify: initialProps.simplify, - answerForms: provideAnswerForm - ? [initialProps.answerType] - : undefined, - strict: initialProps.inexact, - maxError: initialProps.maxError, - status: "correct", // Input-number only allows correct answers - message: "", - }, - ]; - - return { - answers, - size: initialProps.size, - coefficient: false, // input-number doesn't have a coefficient prop - labelText: "", // input-number doesn't have a labelText prop - static: false, // static is always false for numeric-input - }; - } else { - // Otherwise simply return the initialProps as there's no differences - // between v0 and v1 for numeric-input - return initialProps; - } - }; - change = (...args) => { return Changeable.change.apply(this, args); }; diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 06106eef38..9af53b09bb 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -190,7 +190,8 @@ export type { Size, CollinearTuple, MathFormat, - InputNumberWidget, // TODO(jeremy): remove? + InputNumberWidget, // Used for usurpation of InputNumberWidget in perseus-editor + NumericInputWidget, // Used for usurpation of InputNumberWidget in perseus-editor // Widget configuration types PerseusImageBackground, PerseusInputNumberWidgetOptions,