diff --git a/.changeset/clever-kangaroos-mate.md b/.changeset/clever-kangaroos-mate.md
new file mode 100644
index 0000000000..1eb14b063c
--- /dev/null
+++ b/.changeset/clever-kangaroos-mate.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+Make all types in `perseus-types.ts` originate from it (no longer import Mafs types)
diff --git a/.changeset/poor-numbers-reflect.md b/.changeset/poor-numbers-reflect.md
deleted file mode 100644
index 4830ff7a54..0000000000
--- a/.changeset/poor-numbers-reflect.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@khanacademy/perseus": patch
----
-
-[Numeric Input] - BUGFIX - Adjust color contrast of tooltip text
diff --git a/.changeset/selfish-laws-knock.md b/.changeset/selfish-laws-knock.md
new file mode 100644
index 0000000000..3210f747ec
--- /dev/null
+++ b/.changeset/selfish-laws-knock.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+[Dropdown] Change logic for aria-label
diff --git a/docs/architecture.md b/docs/architecture.md
index 9224910412..b57ea3ab44 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -55,17 +55,6 @@ with a short description and the main Perseus data type it accepts.
article, but the scoring is not available outside of the renderer.
-
- MultiItemRenderer
- { _multi: any }
-
- The `MultiItemRenderer` is a more advanced renderer. It accepts an
- object representing a tree of `PerseusRenderer` objects. A `Shape`
- object defines the structure of the tree and you must provide a
- "callback" object of the same structure which defines how and where
- to render each `PerseusRenderer` that appears in the tree.
-
-
Renderer
PerseusRenderer
diff --git a/package.json b/package.json
index 0b39930026..d873e8d270 100644
--- a/package.json
+++ b/package.json
@@ -151,5 +151,6 @@
},
"nyc": {
"report-dir": "coverage/cypress/"
- }
+ },
+ "dependencies": {}
}
diff --git a/packages/perseus-editor/CHANGELOG.md b/packages/perseus-editor/CHANGELOG.md
index 91941befa8..bd18e648bb 100644
--- a/packages/perseus-editor/CHANGELOG.md
+++ b/packages/perseus-editor/CHANGELOG.md
@@ -1,5 +1,25 @@
# @khanacademy/perseus-editor
+## 16.0.0
+
+### Major Changes
+
+- [#1955](https://github.com/Khan/perseus/pull/1955) [`e7b4db0bf`](https://github.com/Khan/perseus/commit/e7b4db0bf193241a36508804dd6e58c729f0a3db) Thanks [@handeyeco](https://github.com/handeyeco)! - Remove support for MultiRenderer
+
+### Patch Changes
+
+- Updated dependencies [[`e7b4db0bf`](https://github.com/Khan/perseus/commit/e7b4db0bf193241a36508804dd6e58c729f0a3db)]:
+ - @khanacademy/perseus@47.0.0
+
+## 15.1.4
+
+### Patch Changes
+
+- [#1976](https://github.com/Khan/perseus/pull/1976) [`2ad163b5e`](https://github.com/Khan/perseus/commit/2ad163b5ea20d40fb9f0edf30e03cd54ecf9bf31) Thanks [@nishasy](https://github.com/nishasy)! - [Locked Figures Aria] Update the auto-generated text to spell out commas
+
+- Updated dependencies [[`e22a931d9`](https://github.com/Khan/perseus/commit/e22a931d987291258b66f2c80db3536970a4555d)]:
+ - @khanacademy/perseus@46.0.1
+
## 15.1.3
### Patch Changes
diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json
index 70db62a16c..349b38cd08 100644
--- a/packages/perseus-editor/package.json
+++ b/packages/perseus-editor/package.json
@@ -3,7 +3,7 @@
"description": "Perseus editors",
"author": "Khan Academy",
"license": "MIT",
- "version": "15.1.3",
+ "version": "16.0.0",
"publishConfig": {
"access": "public"
},
@@ -38,7 +38,7 @@
"@khanacademy/keypad-context": "^1.0.4",
"@khanacademy/kmath": "^0.1.16",
"@khanacademy/math-input": "^21.1.6",
- "@khanacademy/perseus": "^46.0.0",
+ "@khanacademy/perseus": "^47.0.0",
"@khanacademy/perseus-core": "1.5.3",
"@khanacademy/pure-markdown": "^0.3.13",
"mafs": "^0.19.0"
diff --git a/packages/perseus-editor/src/__tests__/i18n.test.ts b/packages/perseus-editor/src/__tests__/i18n.test.ts
deleted file mode 100644
index d909731be4..0000000000
--- a/packages/perseus-editor/src/__tests__/i18n.test.ts
+++ /dev/null
@@ -1,501 +0,0 @@
-import {Dependencies} from "@khanacademy/perseus";
-import _ from "underscore";
-
-import {testDependencies} from "../../../../testing/test-dependencies";
-import i18n from "../i18n";
-import {registerAllWidgetsAndEditorsForTesting} from "../util/register-all-widgets-and-editors-for-testing";
-
-const exerciseImagesEverywhere = {
- question: {
- content:
- "![question-a](question-a)\n\n![question-b](question-b)\n\n[[☃ categorizer 1]]\n\n[[☃ group 1]]\n\n[[☃ image 1]]\n\n[[☃ matcher 1]]\n\n[[☃ matrix 1]]\n\n[[☃ orderer 1]]\n\n[[☃ passage 1]]\n\n[[☃ radio 1]]\n\n[[☃ sorter 1]]\n\n[[☃ table 1]]\n\n[[☃ grapher 1]]\n\n[[☃ interactive-graph 1]]\n\n[[☃ measurer 1]]\n\n[[☃ plotter 1]]",
- images: {},
- widgets: {
- "categorizer 1": {
- type: "categorizer",
- graded: true,
- options: {
- items: [
- "![category-item-a](category-item-a)",
- "![category-item-b](category-item-b)",
- ],
- categories: [
- "![category-category-a](category-category-a)",
- "![category-category-b](category-category-b)",
- ],
- values: [],
- randomizeItems: false,
- },
- version: {major: 0, minor: 0},
- },
- "group 1": {
- type: "group",
- graded: true,
- options: {
- content:
- "![group-a](group-a)\n\n![group-b](group-b)\n\n[[☃ image 1]]",
- images: {},
- widgets: {
- "image 1": {
- type: "image",
- graded: true,
- options: {
- title: "![group-image-title-a](group-image-title-a) ![group-image-title-b](group-image-title-b)",
- range: [
- [0, 10],
- [0, 10],
- ],
- box: [0, 0],
- backgroundImage: {
- url: "group-image-a",
- width: 0,
- height: 0,
- },
- labels: [],
- alt: "",
- caption:
- "![group-image-caption-a](group-image-caption-a) ![group-image-caption-b](group-image-caption-b)",
- },
- version: {major: 0, minor: 0},
- },
- },
- },
- version: {major: 0, minor: 0},
- },
- "image 1": {
- type: "image",
- graded: true,
- options: {
- title: "![image-title-a](image-title-a) ![image-title-b](image-title-b)",
- range: [
- [0, 10],
- [0, 10],
- ],
- box: [0, 0],
- backgroundImage: {url: "image-a", width: 0, height: 0},
- labels: [],
- alt: "",
- caption:
- "![image-caption-a](image-caption-a) ![image-caption-b](image-caption-b)",
- },
- version: {major: 0, minor: 0},
- },
- "matcher 1": {
- type: "matcher",
- graded: true,
- options: {
- left: [
- "![matcher-left-a](matcher-left-a)",
- "![matcher-left-b](matcher-left-b)",
- ],
- right: [
- "![matcher-right-a](matcher-right-a)",
- "![matcher-right-b](matcher-right-b)",
- ],
- labels: [
- "![matcher-label-a](matcher-label-a)",
- "![matcher-label-b](matcher-label-b)",
- ],
- orderMatters: false,
- padding: true,
- },
- version: {major: 0, minor: 0},
- },
- "matrix 1": {
- type: "matrix",
- graded: true,
- options: {
- matrixBoardSize: [3, 3],
- answers: [[]],
- prefix: "![matrix-prefix-a](matrix-prefix-a) ![matrix-prefix-b](matrix-prefix-b)",
- suffix: "![matrix-suffix-a](matrix-suffix-a) ![matrix-suffix-b](matrix-suffix-b)",
- cursorPosition: [0, 0],
- },
- version: {major: 0, minor: 0},
- },
- "orderer 1": {
- type: "orderer",
- graded: true,
- options: {
- options: [
- {content: "![orderer-correct-a](orderer-correct-a)"},
- {content: "![orderer-correct-b](orderer-correct-b)"},
- {content: "![orderer-other-a](orderer-other-a)"},
- {content: "![orderer-other-b](orderer-other-b)"},
- ],
- correctOptions: [
- {content: "![orderer-correct-a](orderer-correct-a)"},
- {content: "![orderer-correct-b](orderer-correct-b)"},
- ],
- otherOptions: [
- {content: "![orderer-other-a](orderer-other-a)"},
- {content: "![orderer-other-b](orderer-other-b)"},
- ],
- height: "normal",
- layout: "horizontal",
- },
- version: {major: 0, minor: 0},
- },
- "passage 1": {
- type: "passage",
- graded: true,
- options: {
- passageTitle:
- "![passage-title-a](passage-title-a) ![passage-title-b](passage-title-b)",
- passageText: "",
- footnotes: "",
- showLineNumbers: true,
- },
- version: {major: 0, minor: 0},
- },
- "radio 1": {
- type: "radio",
- graded: true,
- options: {
- choices: [
- {
- content:
- "![radio-choice1-a](radio-choice1-a) ![radio-choice1-b](radio-choice1-b)",
- },
- {
- content:
- "![radio-choice2-a](radio-choice2-a) ![radio-choice2-b](radio-choice2-b)",
- },
- ],
- randomize: false,
- multipleSelect: false,
- displayCount: null,
- hasNoneOfTheAbove: false,
- onePerLine: true,
- deselectEnabled: false,
- },
- version: {major: 1, minor: 0},
- },
- "sorter 1": {
- type: "sorter",
- graded: true,
- options: {
- correct: [
- "![sorter-correct-a](sorter-correct-a)",
- "![sorter-correct-b](sorter-correct-b)",
- "![sorter-correct-c](sorter-correct-c)",
- ],
- layout: "horizontal",
- padding: true,
- },
- version: {major: 0, minor: 0},
- },
- "table 1": {
- type: "table",
- graded: true,
- options: {
- headers: [
- "![table-header-a](table-header-a)",
- "![table-header-b](table-header-b)",
- "![table-header-c](table-header-c)",
- ],
- rows: 4,
- columns: 3,
- answers: [
- ["", "", ""],
- ["", "", ""],
- ["", "", ""],
- ["", "", ""],
- ],
- },
- version: {major: 0, minor: 0},
- },
- "grapher 1": {
- type: "grapher",
- graded: true,
- options: {
- correct: {type: "linear", coords: null, asymptote: null},
- availableTypes: ["linear"],
- graph: {
- editableSettings: ["graph", "snap", "image"],
- range: [
- [-10, 10],
- [-10, 10],
- ],
- labels: ["x", "y"],
- step: [1, 1],
- gridStep: [1, 1],
- snapStep: [1, 1],
- valid: true,
- backgroundImage: {
- url: "grapher-a",
- width: 32,
- height: 32,
- },
- markings: "graph",
- rulerLabel: "",
- rulerTicks: 10,
- showProtractor: false,
- showRuler: false,
- },
- },
- version: {major: 0, minor: 0},
- },
- "interactive-graph 1": {
- type: "interactive-graph",
- graded: true,
- options: {
- step: [1, 1],
- backgroundImage: {
- url: "interactive-graph-a",
- width: 32,
- height: 32,
- },
- markings: "graph",
- labels: ["x", "y"],
- showProtractor: false,
- showRuler: false,
- rulerLabel: "",
- rulerTicks: 10,
- range: [
- [-10, 10],
- [-10, 10],
- ],
- gridStep: [1, 1],
- snapStep: [0.5, 0.5],
- graph: {type: "linear"},
- correct: {type: "linear", coords: null},
- },
- version: {major: 0, minor: 0},
- },
- "measurer 1": {
- type: "measurer",
- graded: true,
- options: {
- box: [480, 480],
- image: {url: "measurer-a"},
- showProtractor: true,
- showRuler: false,
- rulerLabel: "",
- rulerTicks: 10,
- rulerPixels: 40,
- rulerLength: 10,
- },
- version: {major: 1, minor: 0},
- },
- "plotter 1": {
- type: "plotter",
- graded: true,
- options: {
- correct: [1, 1],
- starting: [1, 1],
- type: "pic",
- labels: ["", ""],
- categories: ["a", "a"],
- scaleY: 1,
- maxY: 10,
- snapsPerLine: 2,
- labelInterval: 1,
- picUrl: "plotter-a",
- },
- version: {major: 0, minor: 0},
- },
- },
- },
- answerArea: {calculator: false},
- itemDataVersion: {major: 0, minor: 1},
- hints: [
- {
- content:
- "![hint1-a](hint1-a)\n\n![hint1-b](hint1-b)\n\n[[☃ image 1]]",
- images: {},
- widgets: {
- "image 1": {
- type: "image",
- graded: true,
- options: {
- title: "![hint1-image-title-a](hint1-image-title-a) ![hint1-image-title-b](hint1-image-title-b)",
- range: [
- [0, 10],
- [0, 10],
- ],
- box: [0, 0],
- backgroundImage: {
- url: "hint1-image-a",
- width: 0,
- height: 0,
- },
- labels: [],
- alt: "",
- caption:
- "![hint1-image-caption-a](hint1-image-caption-a) ![hint1-image-caption-b](hint1-image-caption-b)",
- },
- version: {major: 0, minor: 0},
- },
- },
- },
- {
- content:
- "![hint2-a](hint2-a)\n\n![hint2-b](hint2-b)\n\n[[☃ image 1]]",
- images: {},
- widgets: {
- "image 1": {
- type: "image",
- graded: true,
- options: {
- title: "![hint2-image-title-a](hint2-image-title-a) ![hint2-image-title-b](hint2-image-title-b)",
- range: [
- [0, 10],
- [0, 10],
- ],
- box: [0, 0],
- backgroundImage: {
- url: "hint2-image-a",
- width: 0,
- height: 0,
- },
- labels: [],
- alt: "",
- caption:
- "![hint2-image-caption-a](hint2-image-caption-a) ![hint2-image-caption-b](hint2-image-caption-b)",
- },
- version: {major: 0, minor: 0},
- },
- },
- },
- ],
-} as const;
-
-// Article perseus format is very similar to exercises and should parse the
-// same way, but should just be a list of renders. So we reuse the test data
-// from exerciseImagesEverywhere, but include the question and hints as if they
-// were paragraphs in the article.
-const articleImagesEverywhere = [exerciseImagesEverywhere.question].concat(
- // @ts-expect-error - TS2769 - No overload matches this call.
- exerciseImagesEverywhere.hints,
-);
-
-// All of the images that are in the `exerciseImagesEverywhere` item. Also the
-// same list of images that are in the `articleImagesEverywhere` item.
-const allImages = [
- "question-a",
- "question-b",
- "category-category-a",
- "category-category-b",
- "category-item-a",
- "category-item-b",
- "group-a",
- "group-b",
- "group-image-title-a",
- "group-image-title-b",
- "group-image-a",
- "group-image-caption-a",
- "group-image-caption-b",
- "image-title-a",
- "image-title-b",
- "image-a",
- "image-caption-a",
- "image-caption-b",
- "matcher-label-a",
- "matcher-label-b",
- "matcher-left-a",
- "matcher-left-b",
- "matcher-right-a",
- "matcher-right-b",
- "matrix-prefix-a",
- "matrix-prefix-b",
- "matrix-suffix-a",
- "matrix-suffix-b",
- "orderer-correct-a",
- "orderer-correct-b",
- "orderer-other-a",
- "orderer-other-b",
- "passage-title-a",
- "passage-title-b",
- "radio-choice1-a",
- "radio-choice1-b",
- "radio-choice2-a",
- "radio-choice2-b",
- "sorter-correct-a",
- "sorter-correct-b",
- "sorter-correct-c",
- "table-header-a",
- "table-header-b",
- "table-header-c",
- "grapher-a",
- "interactive-graph-a",
- "measurer-a",
- "plotter-a",
- "hint1-a",
- "hint1-a",
- "hint1-image-title-a",
- "hint1-image-title-b",
- "hint1-image-a",
- "hint1-image-caption-a",
- "hint1-image-caption-b",
- "hint2-a",
- "hint2-a",
- "hint2-image-title-a",
- "hint2-image-title-b",
- "hint2-image-a",
- "hint2-image-caption-a",
- "hint2-image-caption-b",
-];
-
-describe("i18n", () => {
- beforeAll(() => {
- registerAllWidgetsAndEditorsForTesting();
- });
-
- beforeEach(() => {
- jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
- testDependencies,
- );
- });
-
- describe("Exercise image finding", () => {
- it("should find all of the images in items", () => {
- const foundImages = i18n.findImagesInItemData(
- exerciseImagesEverywhere,
- );
-
- expect(foundImages.length <= allImages.length).toBeTruthy();
-
- _.each(allImages, (image) => {
- expect(foundImages.indexOf(image) !== -1).toBeTruthy();
- });
- });
- });
-
- describe("Article image finding", () => {
- it("should find all of the images in items", () => {
- const foundImages = i18n.findImagesInArticles(
- articleImagesEverywhere,
- );
-
- expect(foundImages.length <= allImages.length).toBeTruthy();
-
- _.each(allImages, (image) => {
- expect(foundImages.indexOf(image) !== -1).toBeTruthy();
- });
- });
- });
-
- describe("Multi-item image finding", () => {
- it("should find all of the images in each leaf node", () => {
- const foundImages = i18n.findImagesInItemData({
- _multi: {
- question: {
- __type: "content",
- ...exerciseImagesEverywhere.question,
- },
- hints: exerciseImagesEverywhere.hints.map((hint) => ({
- __type: "hint",
- ...hint,
- })),
- },
- });
-
- expect(foundImages.length <= allImages.length).toBeTruthy();
-
- _.each(allImages, (image) => {
- expect(foundImages.indexOf(image) !== -1).toBeTruthy();
- });
- });
- });
-});
diff --git a/packages/perseus-editor/src/components/graph-settings.tsx b/packages/perseus-editor/src/components/graph-settings.tsx
index 483033dba5..27bc39778b 100644
--- a/packages/perseus-editor/src/components/graph-settings.tsx
+++ b/packages/perseus-editor/src/components/graph-settings.tsx
@@ -92,8 +92,6 @@ const GraphSettings = createReactClass({
},
UNSAFE_componentWillReceiveProps: function (nextProps) {
- // Make sure that state updates when switching
- // between different items in a multi-item editor.
if (
!_.isEqual(this.props.labels, nextProps.labels) ||
!_.isEqual(this.props.gridStep, nextProps.gridStep) ||
diff --git a/packages/perseus-editor/src/diffs/__stories__/structured-item-diff.stories.tsx b/packages/perseus-editor/src/diffs/__stories__/structured-item-diff.stories.tsx
deleted file mode 100644
index eaa4d79b78..0000000000
--- a/packages/perseus-editor/src/diffs/__stories__/structured-item-diff.stories.tsx
+++ /dev/null
@@ -1,855 +0,0 @@
-import * as React from "react";
-
-import StructuredItemDiff from "../structured-item-diff";
-
-import Wrapper from "./perseus-diff-wrapper";
-
-import("../../styles/perseus-editor.less");
-
-type StoryArgs = Record;
-
-type Story = {
- title: string;
- decorators: ReadonlyArray<
- (StoryComponent: typeof React.Component) => React.ReactElement
- >;
-};
-
-export default {
- title: "PerseusEditor/Diffs/Structured Item Diff",
- decorators: [
- (StoryComponent) => (
-
-
-
- ),
- ],
-} as Story;
-
-const tags = {
- a: "a tag",
- b: "b tag",
- c: "c tag",
-} as const;
-
-export const ContentAdded = (args: StoryArgs): React.ReactElement => {
- const props = {
- before: {
- _multi: {
- directions: {
- __type: "content",
- content: "",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "",
- images: {},
- widgets: {},
- },
- hints: [],
- questions: [],
- },
- },
- after: {
- _multi: {
- directions: {
- type: "content",
- content: "directions",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "passage",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- hints: [
- {
- type: "hint",
- content: "hint 1",
- images: {},
- widgets: {},
- },
- ],
- questions: [
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question",
- images: {},
- widgets: {},
- },
- tags: ["a", "b"],
- },
- ],
- },
- },
- shape: {
- type: "object",
- shape: {
- directions: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- passage: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- questions: {
- type: "array",
- elementShape: {
- type: "object",
- shape: {
- tags: {type: "tags"},
- question: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- keepInMind: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- },
- },
- },
- },
- },
- tags: {
- idToName: (id) => tags[id],
- nameToId: (name) => name[0],
- names: ["a tag", "b tag", "c tag"],
- },
- } as const;
-
- return ;
-};
-
-// second instance
-export const ContentAddedRemovedAndChanged = (
- args: StoryArgs,
-): React.ReactElement => {
- const props = {
- before: {
- _multi: {
- directions: {
- __type: "content",
- content: "directions",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "passage",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- hints: [
- {
- type: "hint",
- content: "hint 1",
- images: {},
- widgets: {},
- },
- {
- type: "hint",
- content: "hint 2",
- images: {},
- widgets: {},
- },
- ],
- questions: [
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question",
- images: {},
- widgets: {},
- },
- tags: ["a", "b"],
- },
- ],
- },
- },
- after: {
- _multi: {
- directions: {
- type: "content",
- content: "directions",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "passage",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- hints: [
- {
- type: "hint",
- content: "hint 1",
- images: {},
- widgets: {},
- },
- ],
- questions: [
- {
- hints: [],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question edited",
- images: {},
- widgets: {},
- },
- tags: ["a", "c"],
- },
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question",
- images: {},
- widgets: {},
- },
- tags: ["a", "b"],
- },
- ],
- },
- },
- shape: {
- type: "object",
- shape: {
- directions: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- passage: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- questions: {
- type: "array",
- elementShape: {
- type: "object",
- shape: {
- tags: {type: "tags"},
- question: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- keepInMind: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- },
- },
- },
- },
- },
- tags: {
- idToName: (id) => tags[id],
- nameToId: (name) => name[0],
- names: ["a tag", "b tag", "c tag"],
- },
- } as const;
- return ;
-};
-
-// third instance
-export const MiscContentChanges = (args: StoryArgs): React.ReactElement => {
- const props = {
- before: {
- _multi: {
- directions: {
- __type: "content",
- content: "directions",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "passage",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- hints: [
- {
- type: "hint",
- content: "hint 1",
- images: {},
- widgets: {},
- },
- {
- type: "hint",
- content: "hint 2",
- images: {},
- widgets: {},
- },
- {
- type: "hint",
- content: "hint 3",
- images: {},
- widgets: {},
- },
- ],
- questions: [
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- {
- type: "hint",
- content: "question hint 2",
- images: {},
- widgets: {},
- },
- {
- type: "hint",
- content: "question hint 3",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question edited",
- images: {},
- widgets: {},
- },
- tags: ["a", "c"],
- },
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question",
- images: {},
- widgets: {},
- },
- tags: ["a", "b"],
- },
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question",
- images: {},
- widgets: {},
- },
- tags: ["a", "b"],
- },
- ],
- },
- },
- after: {
- _multi: {
- directions: {
- __type: "content",
- content: "directions",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "passage",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- hints: [
- {
- type: "hint",
- content: "hint 1",
- images: {},
- widgets: {},
- },
- {
- type: "hint",
- content: "hint 2",
- images: {},
- widgets: {},
- },
- ],
- questions: [
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1 edited",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question edited",
- images: {},
- widgets: {},
- },
- tags: ["a", "c"],
- },
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question",
- images: {},
- widgets: {},
- },
- tags: ["a", "b"],
- },
- ],
- },
- },
- shape: {
- type: "object",
- shape: {
- directions: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- passage: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- questions: {
- type: "array",
- elementShape: {
- type: "object",
- shape: {
- tags: {type: "tags"},
- question: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- keepInMind: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- },
- },
- },
- },
- },
- tags: {
- idToName: (id) => tags[id],
- nameToId: (name) => name[0],
- names: ["a tag", "b tag", "c tag"],
- },
- } as const;
-
- return ;
-};
-
-// fourth
-export const ContentRemoved = (args: StoryArgs): React.ReactElement => {
- const props = {
- before: {
- _multi: {
- directions: {
- __type: "content",
- content: "directions",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "passage",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- hints: [
- {
- type: "hint",
- content: "hint 1",
- images: {},
- widgets: {},
- },
- {
- type: "hint",
- content: "hint 2",
- images: {},
- widgets: {},
- },
- ],
- questions: [
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1 edited",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question edited",
- images: {},
- widgets: {},
- },
- tags: ["a", "c"],
- },
- {
- hints: [
- {
- type: "hint",
- content: "question hint 1",
- images: {},
- widgets: {},
- },
- ],
- keepInMind: {
- type: "content",
- content: "keep in mind",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "overview",
- images: {},
- widgets: {},
- },
- question: {
- type: "content",
- content: "question",
- images: {},
- widgets: {},
- },
- tags: ["a", "b"],
- },
- ],
- },
- },
- after: {
- _multi: {
- directions: {
- __type: "content",
- content: "",
- images: {},
- widgets: {},
- },
- passage: {
- type: "content",
- content: "",
- images: {},
- widgets: {},
- },
- overview: {
- type: "content",
- content: "",
- images: {},
- widgets: {},
- },
- hints: [],
- questions: [],
- },
- },
- shape: {
- type: "object",
- shape: {
- directions: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- passage: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- questions: {
- type: "array",
- elementShape: {
- type: "object",
- shape: {
- tags: {type: "tags"},
- question: {
- type: "content",
- },
- overview: {
- type: "content",
- },
- keepInMind: {
- type: "content",
- },
- hints: {
- type: "array",
- elementShape: {
- type: "hint",
- },
- },
- },
- },
- },
- },
- },
- tags: {
- idToName: (id) => tags[id],
- nameToId: (name) => name[0],
- names: ["a tag", "b tag", "c tag"],
- },
- } as const;
-
- return ;
-};
diff --git a/packages/perseus-editor/src/diffs/__tests__/structured-item-diff.test.ts b/packages/perseus-editor/src/diffs/__tests__/structured-item-diff.test.ts
deleted file mode 100644
index 37d29ecac0..0000000000
--- a/packages/perseus-editor/src/diffs/__tests__/structured-item-diff.test.ts
+++ /dev/null
@@ -1,433 +0,0 @@
-import {buildEmptyItemTreeForShape, shapes} from "@khanacademy/perseus";
-
-import StructuredItemDiff from "../structured-item-diff";
-
-import type {Path} from "@khanacademy/perseus";
-
-const gtpPassageShape = shapes.shape({
- directions: shapes.content,
- passage: shapes.content,
- overview: shapes.content,
- hints: shapes.hints,
-
- questions: shapes.arrayOf(
- shapes.shape({
- tags: shapes.tags,
- question: shapes.content,
- keepInMind: shapes.content,
- overview: shapes.content,
- hints: shapes.hints,
- }),
- ),
-});
-
-const gtpSingleQuestionShape = shapes.shape({
- blurb: shapes.content,
- question: shapes.content,
- hints: shapes.hints,
-});
-
-const emptyContent = buildEmptyItemTreeForShape(shapes.content);
-const emptyHint = buildEmptyItemTreeForShape(shapes.hint);
-
-describe("StructuredItemDiff", function () {
- /**
- * Testing adding all empty content items,
- * and then testing removing by reversing the order.
- */
- it("test adding empty items, removing empty items", function () {
- const beforeList = [
- [emptyContent, ["directions"]],
- [emptyContent, ["passage"]],
- [emptyContent, ["overview"]],
- ];
-
- const afterList = [
- [emptyContent, ["directions"]],
- [emptyContent, ["passage"]],
- [emptyContent, ["overview"]],
- [emptyHint, ["hints", 0]],
- [emptyHint, ["hints", 1]],
- [emptyContent, ["questions", 0, "tags"]],
- [emptyContent, ["questions", 0, "question"]],
- [emptyContent, ["questions", 0, "keepInMind"]],
- [emptyContent, ["questions", 0, "overview"]],
- [emptyContent, ["questions", 1, "tags"]],
- [emptyContent, ["questions", 1, "question"]],
- [emptyContent, ["questions", 1, "keepInMind"]],
- [emptyContent, ["questions", 1, "overview"]],
- [emptyHint, ["questions", 1, "hints", 0]],
- [emptyHint, ["questions", 1, "hints", 1]],
- ];
-
- const addingResult: Array = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- beforeList.slice(),
- afterList.slice(),
- addingResult,
- gtpPassageShape,
- [],
- );
-
- const removingResult = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- afterList,
- beforeList,
- removingResult,
- gtpPassageShape,
- [],
- );
-
- expect(addingResult).toEqual([
- ["directions"],
- ["passage"],
- ["overview"],
- ["hints", 0],
- ["hints", 1],
- ["questions", 0, "tags"],
- ["questions", 0, "question"],
- ["questions", 0, "keepInMind"],
- ["questions", 0, "overview"],
- ["questions", 1, "tags"],
- ["questions", 1, "question"],
- ["questions", 1, "keepInMind"],
- ["questions", 1, "overview"],
- ["questions", 1, "hints", 0],
- ["questions", 1, "hints", 1],
- ]);
-
- expect(removingResult).toEqual([
- ["directions"],
- ["passage"],
- ["overview"],
- ["hints", 0],
- ["hints", 1],
- ["questions", 0, "tags"],
- ["questions", 0, "question"],
- ["questions", 0, "keepInMind"],
- ["questions", 0, "overview"],
- ["questions", 1, "tags"],
- ["questions", 1, "question"],
- ["questions", 1, "keepInMind"],
- ["questions", 1, "overview"],
- ["questions", 1, "hints", 0],
- ["questions", 1, "hints", 1],
- ]);
- });
-
- /**
- * Testing editing content of items,
- * including top level items, as well as hints,
- * and items within questions.
- */
- it("test editing items", function () {
- const passage = buildEmptyItemTreeForShape(shapes.content);
- passage.content = "passage";
- const firstQuestion = buildEmptyItemTreeForShape(shapes.content);
- firstQuestion.content = "question 1";
- const firstHint = buildEmptyItemTreeForShape(shapes.hint);
- firstHint.content = "hint 1";
- const secondQuestionFirstHint = buildEmptyItemTreeForShape(shapes.hint);
- secondQuestionFirstHint.content = "question 1 hint 1";
- const beforeList = [
- [emptyContent, ["directions"]],
- [passage, ["passage"]],
- [emptyContent, ["overview"]],
- [firstHint, ["hints", 0]],
- [emptyHint, ["hints", 1]],
- [emptyContent, ["questions", 0, "tags"]],
- [firstQuestion, ["questions", 0, "question"]],
- [emptyContent, ["questions", 0, "keepInMind"]],
- [emptyContent, ["questions", 0, "overview"]],
- [emptyContent, ["questions", 1, "tags"]],
- [emptyContent, ["questions", 1, "question"]],
- [emptyContent, ["questions", 1, "keepInMind"]],
- [emptyContent, ["questions", 1, "overview"]],
- [secondQuestionFirstHint, ["questions", 1, "hints", 0]],
- [emptyHint, ["questions", 1, "hints", 1]],
- ];
-
- const passageEdited = buildEmptyItemTreeForShape(shapes.content);
- passageEdited.content = "passage edited";
- const firstQuestionEdited = buildEmptyItemTreeForShape(shapes.content);
- firstQuestionEdited.content = "question edited 1";
- const firstHintEdited = buildEmptyItemTreeForShape(shapes.hint);
- firstHintEdited.content = "edited hint 1";
- const secondQuestionFirstHintEdited = buildEmptyItemTreeForShape(
- shapes.hint,
- );
- secondQuestionFirstHintEdited.content = "question 1 hint 1 edited";
- const afterList = [
- [emptyContent, ["directions"]],
- [passageEdited, ["passage"]],
- [emptyContent, ["overview"]],
- [firstHintEdited, ["hints", 0]],
- [emptyHint, ["hints", 1]],
- [emptyContent, ["questions", 0, "tags"]],
- [firstQuestionEdited, ["questions", 0, "question"]],
- [emptyContent, ["questions", 0, "keepInMind"]],
- [emptyContent, ["questions", 0, "overview"]],
- [emptyContent, ["questions", 1, "tags"]],
- [emptyContent, ["questions", 1, "question"]],
- [emptyContent, ["questions", 1, "keepInMind"]],
- [emptyContent, ["questions", 1, "overview"]],
- [secondQuestionFirstHintEdited, ["questions", 1, "hints", 0]],
- [emptyHint, ["questions", 1, "hints", 1]],
- ];
-
- const firstResult: Array = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- beforeList.slice(),
- afterList.slice(),
- firstResult,
- gtpPassageShape,
- [],
- );
-
- const secondResult = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- afterList,
- beforeList,
- secondResult,
- gtpPassageShape,
- [],
- );
-
- expect(firstResult).toEqual([
- ["directions"],
- ["passage"],
- ["overview"],
- ["hints", 0],
- ["hints", 1],
- ["questions", 0, "tags"],
- ["questions", 0, "question"],
- ["questions", 0, "keepInMind"],
- ["questions", 0, "overview"],
- ["questions", 1, "tags"],
- ["questions", 1, "question"],
- ["questions", 1, "keepInMind"],
- ["questions", 1, "overview"],
- ["questions", 1, "hints", 0],
- ["questions", 1, "hints", 1],
- ]);
-
- expect(secondResult).toEqual([
- ["directions"],
- ["passage"],
- ["overview"],
- ["hints", 0],
- ["hints", 1],
- ["questions", 0, "tags"],
- ["questions", 0, "question"],
- ["questions", 0, "keepInMind"],
- ["questions", 0, "overview"],
- ["questions", 1, "tags"],
- ["questions", 1, "question"],
- ["questions", 1, "keepInMind"],
- ["questions", 1, "overview"],
- ["questions", 1, "hints", 0],
- ["questions", 1, "hints", 1],
- ]);
- });
-
- /**
- * Testing these cases:
- * (1) adding/removing overall hints
- * (2) adding/removing question hints of a middle question
- * (question is surrounded by other questions)
- * (3) adding/removing question hints of the last question
- * (4) adding/removing questions entirely
- *
- * This test is done with empty items, since we've tested for edits,
- * and generateCompletePathLists() doesn't look at the contents.
- */
- it("testing adding and removing in special cases", function () {
- const beforeList = [
- [emptyContent, ["directions"]],
- [emptyContent, ["passage"]],
- [emptyContent, ["overview"]],
- [emptyContent, ["questions", 0, "tags"]],
- [emptyContent, ["questions", 0, "question"]],
- [emptyContent, ["questions", 0, "keepInMind"]],
- [emptyContent, ["questions", 0, "overview"]],
- [emptyContent, ["questions", 1, "tags"]],
- [emptyContent, ["questions", 1, "question"]],
- [emptyContent, ["questions", 1, "keepInMind"]],
- [emptyContent, ["questions", 1, "overview"]],
- [emptyHint, ["questions", 1, "hints", 0]],
- [emptyHint, ["questions", 1, "hints", 1]],
- [emptyContent, ["questions", 2, "tags"]],
- [emptyContent, ["questions", 2, "question"]],
- [emptyContent, ["questions", 2, "keepInMind"]],
- [emptyContent, ["questions", 2, "overview"]],
- [emptyHint, ["questions", 2, "hints", 0]],
- [emptyHint, ["questions", 2, "hints", 1]],
- [emptyContent, ["questions", 3, "tags"]],
- [emptyContent, ["questions", 3, "question"]],
- [emptyContent, ["questions", 3, "keepInMind"]],
- [emptyContent, ["questions", 3, "overview"]],
- ];
-
- const afterList = [
- [emptyContent, ["directions"]],
- [emptyContent, ["passage"]],
- [emptyContent, ["overview"]],
- [emptyHint, ["hints", 0]], // add
- [emptyHint, ["hints", 1]], // add
- [emptyContent, ["questions", 0, "tags"]],
- [emptyContent, ["questions", 0, "question"]],
- [emptyContent, ["questions", 0, "keepInMind"]],
- [emptyContent, ["questions", 0, "overview"]],
- [emptyHint, ["questions", 0, "hints", 0]], // add
- [emptyHint, ["questions", 0, "hints", 1]], // add
- [emptyContent, ["questions", 1, "tags"]],
- [emptyContent, ["questions", 1, "question"]],
- [emptyContent, ["questions", 1, "keepInMind"]],
- [emptyContent, ["questions", 1, "overview"]],
- [emptyHint, ["questions", 1, "hints", 0]],
- // remove second hint of question 1
- [emptyContent, ["questions", 2, "tags"]],
- [emptyContent, ["questions", 2, "question"]],
- [emptyContent, ["questions", 2, "keepInMind"]],
- [emptyContent, ["questions", 2, "overview"]],
- [emptyHint, ["questions", 2, "hints", 0]],
- [emptyHint, ["questions", 2, "hints", 1]],
- // removed question 2, added two hints to question 3
- ];
-
- const firstResult: Array = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- beforeList.slice(),
- afterList.slice(),
- firstResult,
- gtpPassageShape,
- [],
- );
-
- const secondResult: Array = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- afterList,
- beforeList,
- secondResult,
- gtpPassageShape,
- [],
- );
-
- expect(firstResult).toEqual([
- ["directions"],
- ["passage"],
- ["overview"],
- ["hints", 0],
- ["hints", 1],
- ["questions", 0, "tags"],
- ["questions", 0, "question"],
- ["questions", 0, "keepInMind"],
- ["questions", 0, "overview"],
- ["questions", 0, "hints", 0],
- ["questions", 0, "hints", 1],
- ["questions", 1, "tags"],
- ["questions", 1, "question"],
- ["questions", 1, "keepInMind"],
- ["questions", 1, "overview"],
- ["questions", 1, "hints", 0],
- ["questions", 1, "hints", 1],
- ["questions", 2, "tags"],
- ["questions", 2, "question"],
- ["questions", 2, "keepInMind"],
- ["questions", 2, "overview"],
- ["questions", 2, "hints", 0],
- ["questions", 2, "hints", 1],
- ["questions", 3, "tags"],
- ["questions", 3, "question"],
- ["questions", 3, "keepInMind"],
- ["questions", 3, "overview"],
- ]);
-
- expect(secondResult).toEqual([
- ["directions"],
- ["passage"],
- ["overview"],
- ["hints", 0],
- ["hints", 1],
- ["questions", 0, "tags"],
- ["questions", 0, "question"],
- ["questions", 0, "keepInMind"],
- ["questions", 0, "overview"],
- ["questions", 0, "hints", 0],
- ["questions", 0, "hints", 1],
- ["questions", 1, "tags"],
- ["questions", 1, "question"],
- ["questions", 1, "keepInMind"],
- ["questions", 1, "overview"],
- ["questions", 1, "hints", 0],
- ["questions", 1, "hints", 1],
- ["questions", 2, "tags"],
- ["questions", 2, "question"],
- ["questions", 2, "keepInMind"],
- ["questions", 2, "overview"],
- ["questions", 2, "hints", 0],
- ["questions", 2, "hints", 1],
- ["questions", 3, "tags"],
- ["questions", 3, "question"],
- ["questions", 3, "keepInMind"],
- ["questions", 3, "overview"],
- ]);
- });
-
- /**
- * Testing single question layout shape.
- */
- it("testing single question layout", function () {
- const beforeList = [
- [emptyContent, ["blurb"]],
- [emptyContent, ["question"]],
- ];
-
- const afterList = [
- [emptyContent, ["blurb"]],
- [emptyContent, ["question"]],
- [emptyHint, ["hints", 0]],
- [emptyHint, ["hints", 1]],
- ];
-
- const firstResult = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- beforeList.slice(),
- afterList.slice(),
- firstResult,
- gtpSingleQuestionShape,
- [],
- );
-
- const secondResult = [];
- StructuredItemDiff.generateCompletePathsList(
- // @ts-expect-error - TS2345 - Argument of type 'any[][]' is not assignable to parameter of type 'ItemList[]'.
- afterList,
- beforeList,
- secondResult,
- gtpSingleQuestionShape,
- [],
- );
-
- expect(firstResult).toEqual([
- ["blurb"],
- ["question"],
- ["hints", 0],
- ["hints", 1],
- ]);
-
- expect(secondResult).toEqual([
- ["blurb"],
- ["question"],
- ["hints", 0],
- ["hints", 1],
- ]);
- });
-});
diff --git a/packages/perseus-editor/src/diffs/structured-item-diff.tsx b/packages/perseus-editor/src/diffs/structured-item-diff.tsx
deleted file mode 100644
index 43cccc1298..0000000000
--- a/packages/perseus-editor/src/diffs/structured-item-diff.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-/* eslint-disable @khanacademy/ts-no-error-suppressions */
-/**
- * A side by side diff view for Perseus exercise items
- * that do not have the standard question layout.
- */
-import {
- buildEmptyItemTreeForShape,
- buildMapper,
- itemToTree,
- shapes,
-} from "@khanacademy/perseus";
-import * as React from "react";
-
-import RendererDiff from "./renderer-diff";
-import TagsDiff from "./tags-diff";
-
-import type {Item, Path, Shape} from "@khanacademy/perseus";
-
-type ItemList = [unknown, Path];
-
-/**
- * Outputs true if path begins with beginPath, false otherwise.
- */
-function beginsWith(path: Path, beginPath: Path): boolean {
- let matches = true;
- for (let i = 0; i < beginPath.length; i++) {
- if (i >= path.length) {
- return false;
- }
- if (beginPath[i] !== path[i]) {
- matches = false;
- }
- }
- return matches;
-}
-
-/**
- * Outputs true if beforePath and afterPath are the same.
- */
-function checkPath(beforePath: Path, afterPath: Path): boolean {
- if (beforePath.length !== afterPath.length) {
- return false;
- }
- for (let i = 0, l = beforePath.length; i < l; i++) {
- if (beforePath[i] !== afterPath[i]) {
- return false;
- }
- }
- return true;
-}
-
-/**
- * Given a path, returns a title. Puts colons after numbers in the path.
- */
-function getTitle(path: Path): string {
- const title: Array = [];
- for (let i = 0; i < path.length; i++) {
- if (typeof path[i] === "number") {
- // @ts-expect-error - TS2365 - Operator '+' cannot be applied to types 'string | number' and 'number'.
- title.push((path[i] + 1).toString() + ":");
- } else {
- // @ts-expect-error - TS2345 - Argument of type 'string | number' is not assignable to parameter of type 'string'.
- title.push(path[i]);
- }
- }
- return title.join(" ");
-}
-
-type Tag = {
- idToName: (arg1: string) => string;
- nameToId: (arg1: string) => string;
- names: ReadonlyArray;
-};
-
-type Props = {
- after: Item;
- before: Item;
- shape: Shape;
- tags: Tag;
-};
-
-class StructuredItemDiff extends React.Component {
- /**
- * Traverses the given shape and adds paths that are present in
- * beforeList and afterList to result. Note that this method assumes
- * the order of elements in beforeList and afterList, which are
- * from buildMapper(), is the same order they appear in in the shape.
- */
- static generateCompletePathsList(
- beforeList: Array,
- afterList: Array,
- result: Array,
- shape: Shape,
- path: ReadonlyArray,
- ): void {
- if (
- shape.type === "content" ||
- shape.type === "hint" ||
- shape.type === "tags"
- ) {
- const beforePath =
- beforeList.length > 0 && checkPath(path, beforeList[0][1]);
- const afterPath =
- afterList.length > 0 && checkPath(path, afterList[0][1]);
- if (beforePath && afterPath) {
- result.push(path);
- beforeList.splice(0, 1);
- afterList.splice(0, 1);
- } else if (beforePath) {
- result.push(path);
- beforeList.splice(0, 1);
- } else if (afterPath) {
- result.push(path);
- afterList.splice(0, 1);
- }
- } else if (shape.type === "array") {
- let index = 0;
- let newPath = path.concat(index);
-
- // For array types, the paths will be in the form [, n],
- // where n is an integer > 0 and increments.
- // As long as either beforeList or afterList has a next element that
- // matches [, n], we recurse into that item with the new path.
- while (
- (beforeList.length > 0 &&
- beginsWith(beforeList[0][1], newPath)) ||
- (afterList.length > 0 && beginsWith(afterList[0][1], newPath))
- ) {
- StructuredItemDiff.generateCompletePathsList(
- beforeList,
- afterList,
- result,
- shape.elementShape,
- newPath,
- );
- index++;
- newPath = path.concat(index);
- }
- } else if (shape.type === "object") {
- const keys = Object.keys(shape.shape);
- for (let i = 0; i < keys.length; i++) {
- const newPath = path.concat([keys[i]]);
- StructuredItemDiff.generateCompletePathsList(
- beforeList,
- afterList,
- result,
- shape.shape[keys[i]],
- newPath,
- );
- }
- }
- }
-
- render(): React.ReactNode {
- const {before, after, shape, tags} = this.props;
-
- const beforeList: Array = [];
- const afterList = [];
-
- buildMapper()
- .setContentMapper((c, _, p) => beforeList.push([c, p]))
- .setHintMapper((c, _, p) => beforeList.push([c, p]))
- .setTagsMapper((c, _, p) => beforeList.push([c, p]))
- .mapTree(itemToTree(before), shape);
-
- buildMapper()
- // @ts-expect-error - TS2322 - Type 'unknown' is not assignable to type 'never'. | TS2322 - Type 'Path' is not assignable to type 'never'.
- .setContentMapper((c, _, p) => afterList.push([c, p]))
- // @ts-expect-error - TS2322 - Type 'unknown' is not assignable to type 'never'. | TS2322 - Type 'Path' is not assignable to type 'never'.
- .setHintMapper((c, _, p) => afterList.push([c, p]))
- // @ts-expect-error - TS2322 - Type 'unknown' is not assignable to type 'never'. | TS2322 - Type 'Path' is not assignable to type 'never'.
- .setTagsMapper((c, _, p) => afterList.push([c, p]))
- .mapTree(itemToTree(after), shape);
-
- // These are used in generateCompletePathsList()
- // and are modified in that method.
- const beforeListModified = beforeList.slice();
- const afterListModified = afterList.slice();
-
- const allDiffPaths: Array = [];
- StructuredItemDiff.generateCompletePathsList(
- beforeListModified,
- afterListModified,
- allDiffPaths,
- shape,
- [],
- );
-
- const diffCount = allDiffPaths.length;
-
- const diffs: React.ReactNode = allDiffPaths.map((path, n) => {
- const isTag = path[path.length - 1] === "tags";
- const currentTitle = getTitle(path);
-
- let before = beforeList.find((e) => {
- return checkPath(e[1], path);
- });
- let after = afterList.find((e) => {
- return checkPath(e[1], path);
- });
-
- if (isTag) {
- if (!before) {
- before = [[], path];
- }
- if (!after) {
- // @ts-expect-error - TS2322 - Type 'Path[]' is not assignable to type 'undefined'.
- after = [[], path];
- }
-
- const beforeTags: Array = [];
- if (Array.isArray(before[0])) {
- before[0].forEach((tagId) => {
- if (typeof tagId === "string") {
- beforeTags.push(tags.idToName(tagId));
- }
- });
- }
- const afterTags: Array = [];
- // @ts-expect-error - TS2532 - Object is possibly 'undefined'.
- if (Array.isArray(after[0])) {
- // @ts-expect-error - TS2532 - Object is possibly 'undefined'.
- after[0].forEach((tagId) => {
- if (typeof tagId === "string") {
- afterTags.push(tags.idToName(tagId));
- }
- });
- }
-
- const intersection = beforeTags.filter((tag) =>
- afterTags.includes(tag),
- );
- const beforeOnly = beforeTags.filter(
- (tag) => !afterTags.includes(tag),
- );
- const afterOnly = afterTags.filter(
- (tag) => !beforeTags.includes(tag),
- );
-
- return (
-
- );
- }
- if (!before) {
- before = [buildEmptyItemTreeForShape(shapes.content), path];
- }
- if (!after) {
- // @ts-expect-error - TS2322 - Type 'any[]' is not assignable to type 'undefined'.
- after = [buildEmptyItemTreeForShape(shapes.content), path];
- }
- return (
-
- );
- });
-
- return {diffs}
;
- }
-}
-
-export default StructuredItemDiff;
diff --git a/packages/perseus-editor/src/i18n.ts b/packages/perseus-editor/src/i18n.ts
deleted file mode 100644
index 281b82f962..0000000000
--- a/packages/perseus-editor/src/i18n.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * Functions for extracting data from items for use in i18n.
- */
-import {traverse, MultiItems, PerseusMarkdown} from "@khanacademy/perseus";
-import _ from "underscore";
-
-const {findContentNodesInItem, findHintNodesInItem, inferItemShape} =
- MultiItems;
-
-// Takes a renderer content and parses the markdown for images
-function findImagesInContent(content: any, images: Array) {
- // @ts-expect-error - TS2554 - Expected 2 arguments, but got 1.
- const parsed = PerseusMarkdown.parse(content);
-
- PerseusMarkdown.traverseContent(parsed, function (node) {
- if (node.type === "image") {
- images.push(node.target);
- }
- });
-}
-
-// Background images in some widgets are annoying to deal with because
-// sometimes the objects aren't full when there isn't an image. So, we do some
-// extra checking to make sure we don't cause an error or push an empty image.
-function handleBackgroundImage(graph, images: Array) {
- if (graph && graph.backgroundImage && graph.backgroundImage.url) {
- images.push(graph.backgroundImage.url);
- }
-}
-
-// The callback called for each widget. We check each of the areas of each
-// widget where they contain a renderer for images by calling
-// findImagesInContent. We don't have to recurse through child widgets, because
-// traverseRendererDeep does that for us.
-function widgetCallback(widgetInfo: any, images: Array) {
- if (!widgetInfo.options) {
- return;
- }
-
- // TODO(emily/aria): Move this into the widget files, so we don't have the
- // logic out here.
- if (widgetInfo.type === "categorizer") {
- _.each(widgetInfo.options.items, function (item) {
- findImagesInContent(item, images);
- });
- _.each(widgetInfo.options.categories, function (category) {
- findImagesInContent(category, images);
- });
- } else if (widgetInfo.type === "image") {
- findImagesInContent(widgetInfo.options.title, images);
- findImagesInContent(widgetInfo.options.caption, images);
- } else if (widgetInfo.type === "matcher") {
- _.each(widgetInfo.options.left, function (option) {
- findImagesInContent(option, images);
- });
- _.each(widgetInfo.options.right, function (option) {
- findImagesInContent(option, images);
- });
- _.each(widgetInfo.options.labels, function (label) {
- findImagesInContent(label, images);
- });
- } else if (widgetInfo.type === "matrix") {
- findImagesInContent(widgetInfo.options.prefix, images);
- findImagesInContent(widgetInfo.options.suffix, images);
- } else if (widgetInfo.type === "orderer") {
- _.each(widgetInfo.options.options, function (option) {
- findImagesInContent(option.content, images);
- });
- } else if (widgetInfo.type === "passage") {
- findImagesInContent(widgetInfo.options.passageTitle, images);
- } else if (widgetInfo.type === "radio") {
- _.each(widgetInfo.options.choices, function (choice) {
- findImagesInContent(choice.content, images);
- });
- } else if (widgetInfo.type === "sorter") {
- _.each(widgetInfo.options.correct, function (option) {
- findImagesInContent(option, images);
- });
- } else if (widgetInfo.type === "table") {
- _.each(widgetInfo.options.headers, function (header) {
- findImagesInContent(header, images);
- });
- }
-
- if (widgetInfo.type === "grapher") {
- handleBackgroundImage(widgetInfo.options.graph, images);
- } else if (widgetInfo.type === "image") {
- handleBackgroundImage(widgetInfo.options, images);
- } else if (widgetInfo.type === "interactive-graph") {
- handleBackgroundImage(widgetInfo.options, images);
- } else if (widgetInfo.type === "measurer" && widgetInfo.options.image) {
- images.push(widgetInfo.options.image.url);
- } else if (widgetInfo.type === "plotter") {
- images.push(widgetInfo.options.picUrl);
- }
-}
-
-function findImagesInRenderers(renderers) {
- const images = [];
-
- _.each(renderers, (renderer) => {
- traverse(
- renderer,
- (content) => {
- findImagesInContent(content, images);
- },
- (widget) => widgetCallback(widget, images),
- );
- });
-
- return images;
-}
-
-// Calls findImagesInContent on all of the different content areas for
-// assessment items
-function findImagesInItemData(itemData: any): any {
- let renderers = [];
- if (itemData._multi) {
- const shape = inferItemShape(itemData);
- // @ts-expect-error - TS2345 - Argument of type 'ContentNode' is not assignable to parameter of type 'never'.
- findContentNodesInItem(itemData, shape, (node) => renderers.push(node));
- // @ts-expect-error - TS2345 - Argument of type 'HintNode' is not assignable to parameter of type 'never'.
- findHintNodesInItem(itemData, shape, (node) => renderers.push(node));
- } else {
- // @ts-expect-error - TS2322 - Type 'any' is not assignable to type 'never'. | TS2322 - Type 'any' is not assignable to type 'never'.
- renderers = [itemData.question, ...itemData.hints];
- }
- return findImagesInRenderers(renderers);
-}
-
-// Calls findImagesInContent on all of the different content areas for
-// articles
-function findImagesInArticles(perseusContent: any): any {
- return findImagesInRenderers(perseusContent);
-}
-
-export default {
- findImagesInArticles: findImagesInArticles,
- findImagesInItemData: findImagesInItemData,
-};
diff --git a/packages/perseus-editor/src/index.ts b/packages/perseus-editor/src/index.ts
index 73e1b5921f..964f9b7140 100644
--- a/packages/perseus-editor/src/index.ts
+++ b/packages/perseus-editor/src/index.ts
@@ -5,12 +5,9 @@ export {default as DeviceFramer} from "./components/device-framer";
export {default as ViewportResizer} from "./components/viewport-resizer";
export {default as ArticleDiff} from "./diffs/article-diff";
export {default as ItemDiff} from "./diffs/item-diff";
-export {default as StructuredItemDiff} from "./diffs/structured-item-diff";
export {default as EditorPage} from "./editor-page";
export {default as Editor} from "./editor";
-export {default as i18n} from "./i18n";
export {default as IframeContentRenderer} from "./iframe-content-renderer";
-export {default as MultiRendererEditor} from "./multirenderer-editor";
import "./styles/perseus-editor.less";
diff --git a/packages/perseus-editor/src/multirenderer-editor.tsx b/packages/perseus-editor/src/multirenderer-editor.tsx
deleted file mode 100644
index 2d0445c7fd..0000000000
--- a/packages/perseus-editor/src/multirenderer-editor.tsx
+++ /dev/null
@@ -1,1060 +0,0 @@
-/**
- * Editor for a multi-item question.
- *
- * TODO(mdr): The UI for managing arrays isn't visually consistent with
- * HintsEditor. Should we bring them in line with each other?
- */
-import {
- ApiOptions,
- buildEmptyItemTreeForShape,
- components,
- iconChevronDown,
- iconTrash,
- itemToTree,
- MultiItems,
-} from "@khanacademy/perseus";
-import {StyleSheet, css} from "aphrodite";
-// eslint-disable-next-line import/no-extraneous-dependencies
-import lens from "hubble";
-import * as React from "react";
-import ReactDOM from "react-dom";
-
-import JsonEditor from "./components/json-editor";
-import SimpleButton from "./components/simple-button";
-import Editor from "./editor";
-import {HintEditor} from "./hint-editor";
-
-import type {
- APIOptions,
- ChangeHandler,
- EditorMode,
-
- // Multi-item item types
- Item,
- ItemTree,
- ItemObjectNode,
- ItemArrayNode,
- ContentNode,
- HintNode,
- TagsNode,
-
- // Multi-item shape types
- Shape,
- ArrayShape,
- ObjectShape,
- ContentShape,
- HintShape,
- TagsShape,
-} from "@khanacademy/perseus";
-
-const {InlineIcon} = components;
-const {MultiRenderer} = MultiItems;
-
-// TODO(CP-4849): figure out when $ReadOnlyArray vs $ReadOnlyArray should be used
-type Path = ReadonlyArray;
-
-type ModeDropdownProps = {
- currentMode: EditorMode;
- // A function that takes in a string signifying the mode (ex: "edit")
- onChange: (mode: EditorMode) => unknown;
-};
-
-/**
- * Component that displays the mode dropdown.
- *
- * The mode dropdown is the selector at the top of the editor that lets you
- * switch between edit, preview, and dev-only JSON mode.
- */
-class ModeDropdown extends React.Component {
- _handleSelectMode = (event: React.ChangeEvent) => {
- if (this.props.onChange) {
- // event.target.value corresponds to the options' values below which
- // are limited to EditorMode, but TypeScript doesn't know that so we have
- // to cast through any here.
- const value = event.target.value as EditorMode;
- this.props.onChange(value);
- }
- };
-
- render(): React.ReactNode {
- return (
-
- Mode:{" "}
-
- Edit
- Preview
- Dev-only JSON
-
-
- );
- }
-}
-
-/**
- * Convert a camel-cased string to a human-formatted string.
- * "superCoolThings" -> "super cool things"
- */
-function camelCaseToHuman(str: string) {
- // Decapitalize the capital letters, and add a space before each.
- return str.replace(/[A-Z]/g, (s) => " " + s.toLowerCase());
-}
-
-/**
- * Capitalize the first letter of the given string.
- * "super cool things" -> "Super cool things"
- */
-function capitalize(str: string) {
- return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
-}
-
-/**
- * Convert the given pluralized word to a singularized word.
- * "super cool things" -> "super cool thing"
- */
-function pluralToSingular(str: string) {
- if (str.charAt(str.length - 1) === "s") {
- // Incredibly weak implementation :P
- return str.slice(0, -1);
- }
- // Uh oh, dunno how to singularize anything but the simplest case!
- // Let's just return the plural form, and hope the user forgives the
- // grammatical inconsistency.
- return str;
-}
-
-/**
- * When iterating through the editors, we don't keep track of the extra
- * `_multi` part at the beginning. This is a helper function which takes a path
- * and prepends that key.
- */
-function multiPath(path: Path | Array) {
- return ["_multi", ...path];
-}
-
-// Return an h1 if depth=0, h2 if depth=1, etc.
-// NOTE: This component accepts pass-through props.
-type HeaderProps = {
- depth: number;
-};
-
-const Header = ({depth, ...props}: HeaderProps): React.ReactElement => {
- const headerLevel = Math.min(depth, 5) + 1;
- const HeaderTag = `h${headerLevel}`;
- return ;
-};
-
-// Actions is inexact so that we can pass an instance MultiRendererEditor.
-interface Actions {
- addArrayElement: (path: Path, shape: Shape) => void;
- mergeValueAtPath: (path: Path, newValue?: any) => void;
- setValueAtPath: (path: Path, newValue?: any) => void;
- moveArrayElementDown: (path: Path) => void;
- moveArrayElementUp: (path: Path) => void;
- removeArrayElement: (path: Path) => void;
-}
-
-// This type is used to define prop types for various nodes. The S and D type
-// params should match when used, e.g. the prop types for `HintNodeContent` is
-// `NodePropTypes`.
-type NodePropTypes = {
- shape: S;
- data: D;
- path: Path;
- actions: Actions;
- apiOptions: APIOptions;
- // For the left-hand column, we use edit mode and leave renderers empty.
- // For the right-hand column, we use preview mode and provide renderers
- // via a MultiRenderer.
- // TODO(CP-4850): figure out how to type this, it appears to be a tree where the
- // leaf nodes could be typed using RendererInterface.
- renderers?: any;
-};
-
-/**
- * Render a node in the editor tree, given the shape of the target
- * node, the data stored in the target node, the path to the target
- * node, and any UI controls that affect how this node relates to its
- * parent (e.g. remove from parent array).
- *
- * This returns a container element with a pretty title and additional
- * UI controls for this node. Its contents are produced by
- * `NodeContent`. The two functions are mutually recursive.
- *
- * Leaf nodes, like items and hints, render an editor pod around their
- * content. Container nodes, like arrays and objects, render a header above
- * their content.
- */
-const NodeContainer = (
- props: NodePropTypes & {
- controls?: ReadonlyArray;
- name?: string;
- },
-) => {
- const {
- shape,
- data,
- path,
- actions,
- name: givenName,
- controls,
- ...otherProps
- } = props;
-
- const name = givenName || camelCaseToHuman(path[path.length - 1] || "");
-
- const children = (
-
- );
-
- const key = path.join(".");
-
- if (shape.type === "array") {
- return (
-
- {children}
-
- );
- }
- if (shape.type === "object") {
- return (
-
- {children}
-
- );
- }
- return (
-
- {children}
-
- );
-};
-
-type LeafContainerProps = {
- name: string;
- controls?: React.ReactNode;
- children?: React.ReactNode;
- path: Path;
- shape: Shape;
-};
-const LeafContainer = ({
- name,
- controls,
- children,
- path,
- shape,
-}: LeafContainerProps): React.ReactElement => {
- const hasPreviewHeading = shape.type === "content" || shape.type === "hint";
- const previewHeading = hasPreviewHeading && (
-
- {/* @ts-expect-error - TS2322 - Type '{ children: string; depth: number; className: string; }' is not assignable to type 'IntrinsicAttributes & HeaderProps & { children?: ReactNode; }'. */}
-
-
- );
- return (
-
-
-
-
-
- {capitalize(name)}
-
- {controls}
-
-
- {previewHeading}
-
- {children}
-
- );
-};
-
-interface ArrayContainerActions {
- addArrayElement: (path: Path, shape: Shape) => void;
-}
-
-type ArrayContainerProps = {
- name: string;
- controls?: React.ReactNode;
- children?: React.ReactNode;
- path: Path;
- shape: ArrayShape;
- actions: ArrayContainerActions;
-};
-const ArrayContainer = (props: ArrayContainerProps): React.ReactElement => {
- const {name, controls, children, path, shape, actions} = props;
- return (
-
- {controls && (
-
- {controls}
-
- )}
-
{children}
-
-
- );
-};
-
-type ObjectContainerProps = {
- name: string;
- controls?: React.ReactNode;
- children?: React.ReactNode;
- path: Path;
-};
-const ObjectContainer = ({
- name,
- controls,
- children,
- path,
-}: ObjectContainerProps): React.ReactElement => {
- const headingEditor = (
-
- {/* @ts-expect-error - TS2322 - Type '{ children: string; depth: number; className: string; }' is not assignable to type 'IntrinsicAttributes & HeaderProps & { children?: ReactNode; }'. */}
-
- {controls}
-
- );
- const headingPreview = (name || controls) && (
-
- {/* @ts-expect-error - TS2322 - Type '{ children: string; depth: number; className: string; }' is not assignable to type 'IntrinsicAttributes & HeaderProps & { children?: ReactNode; }'. */}
-
-
- );
- const hasBothHeadings = headingEditor && headingPreview;
- return (
-
- {hasBothHeadings && (
-
-
- {headingEditor}
-
-
- {headingPreview}
-
-
- )}
-
0 && styles.contentIndent)}>
- {children}
-
-
- );
-};
-
-/**
- * Render the content of node in the editor tree, given the shape of
- * the target node, the data stored in the target node, and the path to
- * the target node.
- *
- * If the target node is a leaf, this returns an editor. Otherwise, it
- * iterates over the child nodes, and outputs `NodeContainer` for
- * each of them. The two functions are mutually recursive.
- */
-const NodeContent = (props: NodePropTypes) => {
- const {shape, data, ...restProps} = props;
-
- // All uses of `data` have been cast through any which isn't safe. This was
- // done to avoid introducing new logic which may have resulted in a change
- // of behavior. Also, there doesn't appear to be a way to tell TypeScript the
- // difference between and `TagsNode` and `ItemArrayNode`, see
- // perseus-all-package/multi-items/item-types.js.
- if (shape.type === "content") {
- return (
-
- );
- }
- if (shape.type === "hint") {
- return (
-
- );
- }
- if (shape.type === "tags") {
- return (
-
- );
- }
- if (shape.type === "array") {
- return (
-
- );
- }
- if (shape.type === "object") {
- return (
-
- );
- }
- return null;
-};
-
-type WithStickinessProps = {
- sticky: boolean;
-};
-
-type WithStickiness = T & WithStickinessProps;
-
-/**
- * HOC that adds a "sticky" prop to the wrapped component that is true
- * when the rendered component is taller than the window. Since sticky content
- * can be somewhat distracting, we'd like to avoid it when not useful. This
- * HOC is useful for only making content sticky when useful.
- *
- * It does so by polling the height and comparing it to the window height.
- */
-const withStickiness = <
- Config extends Record,
- Component extends React.ComponentType>,
->(
- WrappedComponent: Component,
-): React.ComponentType => {
- type State = {
- sticky: boolean;
- };
- return class StickyComponent extends React.Component {
- // @ts-expect-error - TS2564 - Property 'stickynessTimer' has no initializer and is not definitely assigned in the constructor.
- stickynessTimer: number;
-
- state = {
- sticky: false,
- };
-
- componentDidMount() {
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
- // eslint-disable-next-line no-restricted-syntax
- // @ts-expect-error - TS2322 - Type 'Timer' is not assignable to type 'number'.
- this.stickynessTimer = setInterval(this.updateStickiness, 1000);
- this.updateStickiness();
- }
-
- componentWillUnmount() {
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
- // eslint-disable-next-line no-restricted-syntax
- clearInterval(this.stickynessTimer);
- }
-
- updateStickiness = () => {
- const domNode = ReactDOM.findDOMNode(this);
- // @ts-expect-error - TS2531 - Object is possibly 'null'. | TS2339 - Property 'offsetHeight' does not exist on type 'Element | Text'.
- const height = domNode.offsetHeight;
- const windowHeight = window.innerHeight;
- const sticky = height > windowHeight;
- if (sticky !== this.state.sticky) {
- this.setState({
- sticky,
- });
- }
- };
-
- render(): React.ReactNode {
- return (
- // @ts-expect-error - TS2322 - Type 'Readonly & { sticky: boolean; children?: ReactNode; }' is not assignable to type 'IntrinsicAttributes & LibraryManagedAttributes>>'.
-
- );
- }
- };
-};
-
-const ItemNodeContent = withStickiness(
- (props: NodePropTypes & WithStickinessProps) => {
- const {data, path, actions, apiOptions, renderers, sticky} = props;
-
- const preview = (
- {lens(renderers).get(path)}
- );
-
- return (
-
-
-
-
- {/* TODO(CP-4852): only pass the props to Editor that it uses. */}
- {
- // @ts-expect-error - TS2769 - No overload matches this call.
-
- actions.mergeValueAtPath(path, newVal)
- }
- apiOptions={apiOptions}
- />
- }
-
-
-
-
-
- );
- },
-);
-
-const HintNodeContent = withStickiness(
- (props: NodePropTypes & WithStickinessProps) => {
- const {data, path, actions, apiOptions, renderers, sticky} = props;
-
- const preview = (
- {lens(renderers).get(path)}
- );
-
- return (
-
-
-
-
- actions.mergeValueAtPath(path, newVal)
- }
- apiOptions={apiOptions}
- showTitle={false}
- showRemoveButton={false}
- showMoveButtons={false}
- // no-op handler since showRemoveButton={false}
- onRemove={() => {}}
- // no-op handler since showMoveButtons={false}
- onMove={(direction: number) => {}}
- isFirst={true}
- isLast={true}
- />
-
-
-
-
- );
- },
-);
-
-const TagsNodeContent = (props: NodePropTypes) => {
- const {data, path, actions, apiOptions} = props;
- const {GroupMetadataEditor} = apiOptions;
-
- if (GroupMetadataEditor == null) {
- return null;
- }
- return (
-
-
- actions.setValueAtPath(path, newVal)}
- showTitle={false}
- />
-
-
- );
-};
-
-const ArrayNodeContent = (props: NodePropTypes) => {
- const {shape, data, path, actions, ...otherProps} = props;
-
- const collectionName = camelCaseToHuman(path[path.length - 1]);
- const elementName = pluralToSingular(collectionName);
-
- const elementType = shape.elementShape.type;
- const elementIsLeaf = elementType === "content" || elementType === "hint";
-
- /**
- * TODO(somewhatabstract, JIRA-XXXX):
- * The NodePropTypes generic and specifically the ItemArrayNode could
- * contain a variety of types that are not arrays. Probably need to refine
- * the type more before doing this work, or rework the `ArrayNodeContent`
- * component to be less permissive about the props it accepts.
- */
- const children = data.map((subdata, i) => {
- const subpath = path.concat(i);
- const controls = [
- i > 0 && (
-
-
actions.moveArrayElementUp(subpath)}
- >
-
-
-
-
-
- ),
- i < data.length - 1 && (
-
- actions.moveArrayElementDown(subpath)}
- >
-
-
-
- ),
-
- actions.removeArrayElement(subpath)}
- >
-
-
-
,
- ];
-
- return (
-
-
-
- );
- });
-
- return {children}
;
-};
-
-const ObjectNodeContent = (
- props: NodePropTypes,
-) => {
- const {shape, data, path, ...otherProps} = props;
-
- // Object iteration order should automatically match the order in which the
- // keys were defined in the object literal. So, whatever order semantically
- // made sense to the shape's author is the order in which we'll iterate :)
- const children = Object.keys(shape.shape).map((subkey) => (
-
-
-
- ));
-
- return {children}
;
-};
-
-interface LayoutStatics {
- shape: Shape;
-}
-
-type MultiRendererEditorProps = {
- // eslint-disable-next-line no-restricted-syntax
- Layout: React.ComponentType & LayoutStatics;
- apiOptions: APIOptions;
- item: Item;
- editorMode: EditorMode;
- onChange: ChangeHandler;
-};
-
-class MultiRendererEditor extends React.Component {
- layout: React.ElementRef | null | undefined;
-
- _renderLayout: () => React.ReactElement = () => {
- const {Layout, apiOptions, item} = this.props;
-
- return (
- (this.layout = node)}
- item={item}
- apiOptions={apiOptions}
- />
- );
- };
-
- _renderJson: () => React.ReactElement> = () => {
- return (
-
- this.props.onChange({editorMode})}
- />
- this.props.onChange({item})}
- />
-
- );
- };
-
- _renderPreview: () => React.ReactElement> =
- () => {
- return (
-
-
- this.props.onChange({editorMode})
- }
- />
- {this._renderLayout()}
-
- );
- };
-
- mergeValueAtPath: (path: Path, newValue?: any) => void = (
- path: Path,
- newValue: unknown,
- ) => {
- this.props.onChange({
- item: lens(this.props.item)
- .merge(multiPath(path), newValue)
- .freeze(),
- });
- };
-
- setValueAtPath: (path: Path, newValue?: any) => void = (
- path: Path,
- newValue: unknown,
- ) => {
- this.props.onChange({
- item: lens(this.props.item).set(multiPath(path), newValue).freeze(),
- });
- };
-
- addArrayElement: (path: Path, shape: Shape) => void = (
- path: Path,
- shape: Shape,
- ) => {
- const currentLength = lens(this.props.item).get(multiPath(path)).length;
- const newElementPath = path.concat(currentLength);
- const newValue = buildEmptyItemTreeForShape(shape);
- this.props.onChange({
- item: lens(this.props.item)
- .set(multiPath(newElementPath), newValue)
- .freeze(),
- });
- };
-
- removeArrayElement: (path: Path) => void = (path: Path) => {
- this.props.onChange({
- item: lens(this.props.item).del(multiPath(path)).freeze(),
- });
- };
-
- moveArrayElementDown: (path: Path) => void = (path: Path) => {
- // Moving an element down can also be expressed as swapping it with the
- // following element.
- const index = path[path.length - 1];
- const nextElementIndex = index + 1;
- const nextElementPath = path.slice(0, -1).concat(nextElementIndex);
-
- const element = lens(this.props.item).get(multiPath(path));
- const nextElement = lens(this.props.item).get(
- multiPath(nextElementPath),
- );
-
- this.props.onChange({
- item: lens(this.props.item)
- .set(multiPath(path), nextElement)
- .set(multiPath(nextElementPath), element)
- .freeze(),
- });
- };
-
- moveArrayElementUp: (path: Path) => void = (path: Path) => {
- // Moving an element up can also be expressed as moving the previous
- // element down.
- const index = path[path.length - 1];
- const previousElementPath = path.slice(0, -1).concat(index - 1);
- this.moveArrayElementDown(previousElementPath);
- };
-
- _renderEdit: () => React.ReactElement> = () => {
- const apiOptions = {
- ...ApiOptions.defaults,
- ...this.props.apiOptions,
- } as const;
-
- const {item} = this.props;
- const itemShape: Shape = this.props.Layout.shape;
-
- return (
-
- this.props.onChange({editorMode})}
- />
-
- {}},
- useVideo: (() => {}) as any,
- }}
- >
- {({renderers}) => (
-
- )}
-
-
- );
- };
-
- score: () => any | undefined = () => {
- if (this.layout) {
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- return this.layout.score();
- }
- };
-
- getSerializedState: () => any | undefined = () => {
- if (this.layout) {
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- return this.layout.getSerializedState();
- }
- };
-
- restoreSerializedState: (state?: any) => void = (state: any) => {
- if (this.layout) {
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- this.layout.restoreSerializedState(state);
- }
- };
-
- _renderContent: () =>
- | React.ReactElement>
- | React.ReactNode = () => {
- switch (this.props.editorMode) {
- case "json":
- return this._renderJson();
- case "preview":
- return this._renderPreview();
- case "edit":
- return this._renderEdit();
- default:
- return (
-
- this.props.onChange({
- editorMode,
- })
- }
- />
- );
- }
- };
-
- render(): React.ReactNode {
- return {this._renderContent()}
;
- }
-}
-
-const styles = StyleSheet.create({
- // This is used in a number of places throughout this file.
- container: {},
-
- // eslint-disable-next-line react-native/no-unused-styles
- editor: {
- width: "100%",
- },
-
- // eslint-disable-next-line react-native/no-unused-styles
- treePreview: {
- position: "relative",
- },
-
- verticalFlip: {
- transform: "scaleY(-1)",
- },
-
- control: {
- marginLeft: 12,
- },
-
- containerHeader: {
- alignItems: "flex-end",
- display: "flex",
- flexDirection: "row",
- },
-
- previewCollectionHeader: {
- marginBottom: 16,
- },
-
- containerTitle: {
- flexGrow: 1,
- margin: 0,
- },
-
- contentIndent: {
- marginLeft: 8,
- },
-
- hintEditor: {
- paddingBottom: 0,
- },
-
- arrayElement: {
- marginBottom: 16,
- },
-
- // Leaf nodes are already wrapped in cute little pods, so they don't need
- // this extra border between array elements.
- arrayElementAndNotLeaf: {
- borderBottom: "1px solid #ccc",
- ":first-child": {
- borderTop: "1px solid #ccc",
- paddingTop: 16,
- },
- },
-
- objectElement: {
- marginBottom: 16,
- },
-
- tagsEditor: {
- border: "1px solid #ddd",
- padding: "5px 10px",
- },
-
- /**
- * A row contains a fixed width editor and a preview that expands as
- * needed.
- */
- row: {
- display: "flex",
- position: "relative",
- },
-
- /**
- * The editor.
- */
- columnLeft: {
- width: 360,
- marginRight: 30,
- // so that the `position: absolute` of line markers are positioned
- // relative to this.
- position: "relative",
- },
-
- /**
- * The preview.
- */
- columnRight: {
- flex: 1,
- marginLeft: 30,
- position: "relative",
- },
-
- /**
- * Sticks to just under the heading.
- */
- sticky: {
- position: "sticky",
- top: 33, // height of the cute pod for the editor
- },
-
- /**
- * Used for sticky headings.
- */
- rowHeading: {
- position: "sticky",
- backgroundColor: "white",
- width: "100%",
- // TODO(joshuan): Make this less arbitrary. It should be higher than
- // perseus content.
- zIndex: 101,
- top: -1,
- },
-});
-
-export default MultiRendererEditor;
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx
index 2ae43e2008..73a1ef623e 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx
@@ -159,8 +159,6 @@ class InteractiveGraphSettings extends React.Component {
}
UNSAFE_componentWillReceiveProps(nextProps) {
- // Make sure that state updates when switching
- // between different items in a multi-item editor.
if (
!_.isEqual(this.props.labels, nextProps.labels) ||
!_.isEqual(this.props.gridStep, nextProps.gridStep) ||
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.test.tsx
index 056ab99f53..5f20a34d33 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.test.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.test.tsx
@@ -393,7 +393,7 @@ describe("LockedEllipseSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Circle with radius 2, centered at (0, 0). Appearance solid gray border, with no fill.",
+ "Circle with radius 2, centered at 0 comma 0. Appearance solid gray border, with no fill.",
});
});
@@ -421,7 +421,7 @@ describe("LockedEllipseSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Circle with radius 2, centered at (0, 0). Appearance solid gray border, with no fill.",
+ "Circle with radius 2, centered at 0 comma 0. Appearance solid gray border, with no fill.",
});
});
@@ -448,7 +448,7 @@ describe("LockedEllipseSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Ellipse with x radius 2 and y radius 3, centered at (0, 0). Appearance solid gray border, with no fill.",
+ "Ellipse with x radius 2 and y radius 3, centered at 0 comma 0. Appearance solid gray border, with no fill.",
});
});
@@ -476,7 +476,7 @@ describe("LockedEllipseSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Ellipse with x radius 2 and y radius 3, centered at (0, 0), rotated by 90 degrees. Appearance solid gray border, with no fill.",
+ "Ellipse with x radius 2 and y radius 3, centered at 0 comma 0, rotated by 90 degrees. Appearance solid gray border, with no fill.",
});
});
@@ -508,7 +508,7 @@ describe("LockedEllipseSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Circle spoken A with radius 2, centered at (0, 0). Appearance solid gray border, with no fill.",
+ "Circle spoken A with radius 2, centered at 0 comma 0. Appearance solid gray border, with no fill.",
});
});
@@ -544,7 +544,7 @@ describe("LockedEllipseSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Circle spoken A, spoken B with radius 2, centered at (0, 0). Appearance solid gray border, with no fill.",
+ "Circle spoken A, spoken B with radius 2, centered at 0 comma 0. Appearance solid gray border, with no fill.",
});
});
});
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.tsx
index 48d7066586..91ec4936c4 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-ellipse-settings.tsx
@@ -79,7 +79,7 @@ const LockedEllipseSettings = (props: Props) => {
str += `Ellipse${visiblelabel} with x radius ${radius[0]} and y radius ${radius[1]}`;
}
- str += `, centered at (${center[0]}, ${center[1]})`;
+ str += `, centered at ${center[0]} comma ${center[1]}`;
if (!isCircle && angle !== 0) {
str += `, rotated by ${radianToDegree(angle)} degrees`;
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.test.tsx
index 0d444b82e3..44f40c0183 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.test.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.test.tsx
@@ -625,7 +625,7 @@ describe("LockedLineSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Segment from point at (0, 0) to point at (2, 2). Appearance solid gray.",
+ "Segment from point at 0 comma 0 to point at 2 comma 2. Appearance solid gray.",
});
});
@@ -651,7 +651,7 @@ describe("LockedLineSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Line from point at (0, 0) to point at (2, 2). Appearance solid gray.",
+ "Line from point at 0 comma 0 to point at 2 comma 2. Appearance solid gray.",
});
});
@@ -682,7 +682,7 @@ describe("LockedLineSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Line spoken A from point at (0, 0) to point at (2, 2). Appearance solid gray.",
+ "Line spoken A from point at 0 comma 0 to point at 2 comma 2. Appearance solid gray.",
});
});
@@ -717,7 +717,7 @@ describe("LockedLineSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Line spoken A, spoken B from point at (0, 0) to point at (2, 2). Appearance solid gray.",
+ "Line spoken A, spoken B from point at 0 comma 0 to point at 2 comma 2. Appearance solid gray.",
});
});
@@ -758,7 +758,7 @@ describe("LockedLineSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Line spoken A from point spoken C at (0, 0) to point spoken D at (2, 2). Appearance solid gray.",
+ "Line spoken A from point spoken C at 0 comma 0 to point spoken D at 2 comma 2. Appearance solid gray.",
});
});
@@ -809,7 +809,7 @@ describe("LockedLineSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Line spoken A, spoken B from point spoken C, spoken C2 at (0, 0) to point spoken D, spoken D2 at (2, 2). Appearance solid gray.",
+ "Line spoken A, spoken B from point spoken C, spoken C2 at 0 comma 0 to point spoken D, spoken D2 at 2 comma 2. Appearance solid gray.",
});
});
});
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx
index 982375bd88..da368b5f42 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-line-settings.tsx
@@ -83,7 +83,7 @@ const LockedLineSettings = (props: Props) => {
const point1VisibleLabel = await joinLabelsAsSpokenMath(point1.labels);
const point2VisibleLabel = await joinLabelsAsSpokenMath(point2.labels);
- let str = `${capitalizeKind}${visiblelabel} from point${point1VisibleLabel} at (${point1.coord[0]}, ${point1.coord[1]}) to point${point2VisibleLabel} at (${point2.coord[0]}, ${point2.coord[1]})`;
+ let str = `${capitalizeKind}${visiblelabel} from point${point1VisibleLabel} at ${point1.coord[0]} comma ${point1.coord[1]} to point${point2VisibleLabel} at ${point2.coord[0]} comma ${point2.coord[1]}`;
const lineAppearance = generateLockedFigureAppearanceDescription(
lineColor,
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx
index 8f772e4ed6..026d873042 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx
@@ -422,7 +422,7 @@ describe("LockedPointSettings", () => {
// generateSpokenMathDetails is mocked to return the input string
// with "Spoken math details for " prepended.
expect(onChangeProps).toHaveBeenCalledWith({
- ariaLabel: "Point at (0, 0). Appearance solid gray.",
+ ariaLabel: "Point at 0 comma 0. Appearance solid gray.",
});
});
@@ -454,7 +454,7 @@ describe("LockedPointSettings", () => {
// generateSpokenMathDetails is mocked to return the input string
// with "Spoken math details for " prepended.
expect(onChangeProps).toHaveBeenCalledWith({
- ariaLabel: "Point spoken A at (0, 0). Appearance solid gray.",
+ ariaLabel: "Point spoken A at 0 comma 0. Appearance solid gray.",
});
});
@@ -491,7 +491,7 @@ describe("LockedPointSettings", () => {
// with "Spoken math details for " prepended.
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Point spoken A, spoken B at (0, 0). Appearance solid gray.",
+ "Point spoken A, spoken B at 0 comma 0. Appearance solid gray.",
});
});
});
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx
index bceb0c2ddf..757ad272f9 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx
@@ -119,7 +119,7 @@ const LockedPointSettings = (props: Props) => {
async function getPrepopulatedAriaLabel() {
const visiblelabel = await joinLabelsAsSpokenMath(labels);
- let str = `Point${visiblelabel} at (${coord[0]}, ${coord[1]})`;
+ let str = `Point${visiblelabel} at ${coord[0]} comma ${coord[1]}`;
const pointAppearance =
generateLockedFigureAppearanceDescription(pointColor);
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx
index 62c3dd0a0a..35d485736a 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx
@@ -608,7 +608,7 @@ describe("LockedPolygonSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Polygon with 3 sides, vertices at (0, 0), (0, 1), (1, 1). Appearance solid gray border, with no fill.",
+ "Polygon with 3 sides, vertices at 0 comma 0, 0 comma 1, 1 comma 1. Appearance solid gray border, with no fill.",
});
});
@@ -644,7 +644,7 @@ describe("LockedPolygonSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Polygon spoken A with 3 sides, vertices at (0, 0), (0, 1), (1, 1). Appearance solid gray border, with no fill.",
+ "Polygon spoken A with 3 sides, vertices at 0 comma 0, 0 comma 1, 1 comma 1. Appearance solid gray border, with no fill.",
});
});
@@ -684,7 +684,7 @@ describe("LockedPolygonSettings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Polygon spoken A, spoken B with 3 sides, vertices at (0, 0), (0, 1), (1, 1). Appearance solid gray border, with no fill.",
+ "Polygon spoken A, spoken B with 3 sides, vertices at 0 comma 0, 0 comma 1, 1 comma 1. Appearance solid gray border, with no fill.",
});
});
});
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx
index e5b82f114c..5619442737 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx
@@ -75,7 +75,7 @@ const LockedPolygonSettings = (props: Props) => {
let str = `Polygon${visiblelabel} with ${points.length} sides, vertices at `;
// Add the coordinates of each point to the aria label
- str += points.map(([x, y]) => `(${x}, ${y})`).join(", ");
+ str += points.map(([x, y]) => `${x} comma ${y}`).join(", ");
const polygonAppearance = generateLockedFigureAppearanceDescription(
color,
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx
index 1c52f82937..fe208e4aa5 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx
@@ -441,7 +441,7 @@ describe("Locked Vector Settings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Vector from (0, 0) to (2, 2). Appearance solid gray.",
+ "Vector from 0 comma 0 to 2 comma 2. Appearance solid gray.",
});
});
@@ -472,7 +472,7 @@ describe("Locked Vector Settings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Vector spoken A from (0, 0) to (2, 2). Appearance solid gray.",
+ "Vector spoken A from 0 comma 0 to 2 comma 2. Appearance solid gray.",
});
});
@@ -507,7 +507,7 @@ describe("Locked Vector Settings", () => {
// Assert
expect(onChangeProps).toHaveBeenCalledWith({
ariaLabel:
- "Vector spoken A, spoken B from (0, 0) to (2, 2). Appearance solid gray.",
+ "Vector spoken A, spoken B from 0 comma 0 to 2 comma 2. Appearance solid gray.",
});
});
});
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx
index 9fee83fc04..2ae3247bc1 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx
@@ -72,7 +72,7 @@ const LockedVectorSettings = (props: Props) => {
async function getPrepopulatedAriaLabel() {
const visiblelabel = await joinLabelsAsSpokenMath(labels);
- let str = `Vector${visiblelabel} from (${tail[0]}, ${tail[1]}) to (${tip[0]}, ${tip[1]})`;
+ let str = `Vector${visiblelabel} from ${tail[0]} comma ${tail[1]} to ${tip[0]} comma ${tip[1]}`;
const vectorAppearance =
generateLockedFigureAppearanceDescription(lineColor);
diff --git a/packages/perseus/CHANGELOG.md b/packages/perseus/CHANGELOG.md
index 4b2c5b0579..6d27ebfa82 100644
--- a/packages/perseus/CHANGELOG.md
+++ b/packages/perseus/CHANGELOG.md
@@ -1,5 +1,17 @@
# @khanacademy/perseus
+## 47.0.0
+
+### Major Changes
+
+- [#1955](https://github.com/Khan/perseus/pull/1955) [`e7b4db0bf`](https://github.com/Khan/perseus/commit/e7b4db0bf193241a36508804dd6e58c729f0a3db) Thanks [@handeyeco](https://github.com/handeyeco)! - Remove support for MultiRenderer
+
+## 46.0.1
+
+### Patch Changes
+
+- [#1966](https://github.com/Khan/perseus/pull/1966) [`e22a931d9`](https://github.com/Khan/perseus/commit/e22a931d987291258b66f2c80db3536970a4555d) Thanks [@mark-fitzgerald](https://github.com/mark-fitzgerald)! - [Numeric Input] - BUGFIX - Adjust color contrast of tooltip text
+
## 46.0.0
### Major Changes
diff --git a/packages/perseus/package.json b/packages/perseus/package.json
index e07e2d5110..c898f2ecb0 100644
--- a/packages/perseus/package.json
+++ b/packages/perseus/package.json
@@ -3,7 +3,7 @@
"description": "Core Perseus API (includes renderers and widgets)",
"author": "Khan Academy",
"license": "MIT",
- "version": "46.0.0",
+ "version": "47.0.0",
"publishConfig": {
"access": "public"
},
diff --git a/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap b/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap
index 160e3f6b89..73f63e6741 100644
--- a/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap
+++ b/packages/perseus/src/__tests__/__snapshots__/renderer.test.tsx.snap
@@ -350,7 +350,7 @@ exports[`renderer snapshots correct answer: correct answer 1`] = `
>
Test visible label
@@ -370,7 +370,6 @@ exports[`renderer snapshots correct answer: correct answer 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Test ARIA label"
- aria-labelledby="uid-dropdown-widget-2-dropdown-label"
class="button_vr44p2-o_O-shared_u51dsh-o_O-default_3ie67y"
id="uid-dropdown-widget-2-dropdown"
role="combobox"
@@ -429,7 +428,7 @@ exports[`renderer snapshots incorrect answer: incorrect answer 1`] = `
>
Test visible label
@@ -449,7 +448,6 @@ exports[`renderer snapshots incorrect answer: incorrect answer 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Test ARIA label"
- aria-labelledby="uid-dropdown-widget-4-dropdown-label"
class="button_vr44p2-o_O-shared_u51dsh-o_O-default_3ie67y"
id="uid-dropdown-widget-4-dropdown"
role="combobox"
@@ -508,7 +506,7 @@ exports[`renderer snapshots initial render: initial render 1`] = `
>
Test visible label
@@ -528,7 +526,6 @@ exports[`renderer snapshots initial render: initial render 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Test ARIA label"
- aria-labelledby="uid-dropdown-widget-0-dropdown-label"
class="button_vr44p2-o_O-shared_u51dsh-o_O-default_3ie67y"
id="uid-dropdown-widget-0-dropdown"
role="combobox"
diff --git a/packages/perseus/src/__tests__/a11y.test.ts b/packages/perseus/src/__tests__/a11y.test.ts
index 5a83c58828..c1c101b96d 100644
--- a/packages/perseus/src/__tests__/a11y.test.ts
+++ b/packages/perseus/src/__tests__/a11y.test.ts
@@ -275,29 +275,6 @@ describe("a11y", () => {
const result = violatingWidgets(emptyImageWithoutAltText);
expect(result).toHaveLength(0);
});
-
- it("should handle all these same cases in multi-items", () => {
- const decorateItem = (item) => ({
- __type: "content",
- ...item.question,
- });
- const result = violatingWidgets({
- _multi: {
- sharedContext: decorateItem(oneInaccessibleWidget),
- questions: [
- decorateItem(noWidgets),
- decorateItem(oneAccessibleWidget),
- decorateItem(imageWithAltText),
- decorateItem(imageWithoutAltText),
- decorateItem(emptyImageWithoutAltText),
- ],
- },
- });
- result.sort(); // don't depend on iteration order
- expect(result).toHaveLength(2);
- expect(result[0]).toBe("image");
- expect(result[1]).toBe("matrix");
- });
});
});
});
diff --git a/packages/perseus/src/__tests__/renderability.test.ts b/packages/perseus/src/__tests__/renderability.test.ts
index 7226d73d96..d1076f8a5c 100644
--- a/packages/perseus/src/__tests__/renderability.test.ts
+++ b/packages/perseus/src/__tests__/renderability.test.ts
@@ -416,65 +416,5 @@ describe("Renderability", () => {
expect(result).toBe(false);
});
});
-
- describe("Multi-items", () => {
- it("should be renderable with no items", () => {
- const result = isItemRenderableByVersion(
- {
- _multi: {
- questions: [],
- },
- },
- PerseusItemVersion,
- );
- expect(result).toBe(true);
- });
-
- it("should be renderable when all items are", () => {
- const result = isItemRenderableByVersion(
- {
- _multi: {
- sharedContext: {
- __type: "content",
- ...sampleV0InputNumberItem.question,
- },
- questions: [
- {
- __type: "content",
- ...sampleV1MeasurerItem.question,
- },
- ],
- },
- },
- PerseusItemVersion,
- );
- expect(result).toBe(true);
- });
-
- it("should not be renderable when one item is not", () => {
- const result = isItemRenderableByVersion(
- {
- _multi: {
- sharedContext: {
- __type: "content",
- ...sampleV0InputNumberItem.question,
- },
- questions: [
- {
- __type: "content",
- ...sampleImpossibleWidgetsItem.question,
- },
- {
- __type: "content",
- ...sampleV1MeasurerItem.question,
- },
- ],
- },
- },
- PerseusItemVersion,
- );
- expect(result).toBe(false);
- });
- });
});
});
diff --git a/packages/perseus/src/a11y.ts b/packages/perseus/src/a11y.ts
index 2803f6db7f..778d79689b 100644
--- a/packages/perseus/src/a11y.ts
+++ b/packages/perseus/src/a11y.ts
@@ -5,12 +5,9 @@
import _ from "underscore";
-import MultiItems from "./multi-items";
import {traverse} from "./traversal";
import * as Widgets from "./widgets";
-const {findContentNodesInItem, inferItemShape} = MultiItems;
-
// Iterate over a single Perseus renderer, mutating `widgets` by appending
// violating widget types discovered in this item.
function traverseRenderer(itemData, widgets: Array) {
@@ -31,14 +28,7 @@ export function violatingWidgets(itemData: any): any {
// TODO(jordan): Hints as well
const widgets = [];
- if (itemData._multi) {
- const shape = inferItemShape(itemData);
- findContentNodesInItem(itemData, shape, (content) =>
- traverseRenderer(content, widgets),
- );
- } else {
- traverseRenderer(itemData.question, widgets);
- }
+ traverseRenderer(itemData.question, widgets);
// Uniquify the list of widgets (by type)
return _.uniq(widgets);
diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts
index 3c43155d69..9c071022e0 100644
--- a/packages/perseus/src/index.ts
+++ b/packages/perseus/src/index.ts
@@ -229,39 +229,11 @@ export type {
PerseusWidget,
PerseusWidgetsMap,
PerseusWidgetTypes,
- MultiItem,
WidgetOptions,
} from "./perseus-types";
export type {UserInputMap} from "./validation.types";
export type {Coord} from "./interactive2/types";
export type {MarkerType} from "./widgets/label-image/types";
-
-/**
- * Multi-items
- */
-export {default as MultiItems} from "./multi-items";
-export {buildEmptyItemTreeForShape, itemToTree} from "./multi-items/items";
-export {default as shapes} from "./multi-items/shapes";
-export {buildMapper} from "./multi-items/trees";
-
-export type {
- ContentNode,
- HintNode,
- Item,
- ItemTree,
- ItemObjectNode,
- ItemArrayNode,
- TagsNode,
-} from "./multi-items/item-types";
-export type {
- Shape,
- ArrayShape,
- ObjectShape,
- ContentShape,
- HintShape,
- TagsShape,
-} from "./multi-items/shape-types";
-export type {Path} from "./multi-items/trees";
export type {
RendererPromptJSON,
WidgetPromptJSON,
diff --git a/packages/perseus/src/multi-items.ts b/packages/perseus/src/multi-items.ts
deleted file mode 100644
index d347764828..0000000000
--- a/packages/perseus/src/multi-items.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * This library provides support for Perseus multi-items: structured Perseus
- * content that content creators can easily create, and that applications can
- * easily render into different parts of the layout.
- *
- * For more details about application and motivation, see:
- * https://sites.google.com/a/khanacademy.org/forge/for-developers/perseus-items-and-multi-items
- *
- * This file primarily exposes the `MultiRenderer` component, which performs
- * multi-rendering. To multi-render a question, pass in the content of the item
- * to the `MultiRenderer` component as a props. Then, pass in a function which
- * takes an object of renderers (in the same structure as the content), and
- * return a render tree. The `MultiRenderer` component will allow you to
- * combine scores, serialized state, etc. without having to manually call on
- * each of the functions. It also handles inter-widgets requests between the
- * different renderers.
- * For more details, see `multi-items/multi-renderer.jsx`.
- *
- * Example:
- *
- * item = {_multi: {
- * left: ,
- * right: [, ],
- * }}
- * shape = shapes.shape({
- * left: shapes.content,
- * right: shapes.arrayOf(shapes.content),
- * })
- *
- *
- * {({renderers}) =>
- *
- *
{renderers.left}
- *
- * {renderers.right.map(r => {r} )}
- *
- *
- * }
- *
- *
- * This file also exposes `shapes`, which helps you construct a runtime type
- * declaration for your particular class of multi-item. This can then be used
- * to create a MultirendererEditor for your multi-item shape, and to validate
- * that a multi-item conforms to the shape via `buildPropTypeForShape`.
- * For more details, see `multi-items/shapes.js`.
- *
- * This file also exposes some utility functions for working with generic
- * multi-items, like `findContentNodesInItem`, `findHintNodesInItem`,
- * `inferItemShape`, and `buildEmptyItemForShape`.
- * For more details, see `multi-items/items.js`.
- */
-import {
- buildEmptyItemForShape,
- findContentNodesInItem,
- findHintNodesInItem,
- inferItemShape,
-} from "./multi-items/items";
-import MultiRenderer from "./multi-items/multi-renderer";
-import {buildPropTypeForShape} from "./multi-items/prop-type-builders";
-import shapes from "./multi-items/shapes";
-
-export default {
- // Tools for rendering your multi-items
- MultiRenderer,
-
- // Tools for declaring your multi-item shapes
- shapes,
- buildPropTypeForShape,
-
- // Tools for generically manipulating multi-items
- buildEmptyItemForShape,
- findContentNodesInItem,
- findHintNodesInItem,
- inferItemShape,
-};
diff --git a/packages/perseus/src/multi-items/__stories__/multi-renderer.stories.tsx b/packages/perseus/src/multi-items/__stories__/multi-renderer.stories.tsx
deleted file mode 100644
index 04921926dd..0000000000
--- a/packages/perseus/src/multi-items/__stories__/multi-renderer.stories.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import {View} from "@khanacademy/wonder-blocks-core";
-import {HeadingSmall} from "@khanacademy/wonder-blocks-typography";
-import {StyleSheet} from "aphrodite";
-import * as React from "react";
-
-import {MultiItemRendererWithDebugUI} from "../../../../../testing/multi-item-renderer-with-debug-ui";
-import {question1} from "../__testdata__/multi-renderer.testdata";
-
-type StoryArgs = Record;
-
-type Story = {
- title: string;
-};
-
-export const SingleItem = (args: StoryArgs): React.ReactElement => {
- const item = {
- _multi: {
- ...question1._multi,
- blurb: {
- ...question1._multi.blurb,
- content:
- "This is a short snippet to help you understand the context of the question. We call it the 'blurb'.",
- },
- },
- } as const;
- return (
-
- {({renderers}) => {
- const {blurb, question, hints} = renderers;
- return (
-
-
-
- Blurb
-
- {blurb}
-
-
-
- Question
-
- {question}
-
-
-
- Hints
-
-
- {// @ts-expect-error [FEI-5003] - TS2339 - Property 'firstN' does not exist on type 'readonly ReactNode[]'.
- hints?.firstN(2)}
-
-
-
- );
- }}
-
- );
-};
-
-const styles = StyleSheet.create({
- section: {
- backgroundColor: "#F5F5F5",
- padding: "5px",
- borderWidth: "1px",
- marginTop: "5px",
- marginBottom: "5px",
- },
- heading: {
- backgroundColor: "#A9A9A9",
- margin: "-5px",
- padding: "5px",
- },
- hints: {
- marginLeft: "50px",
- },
-});
-
-export default {
- title: "Perseus/Renderers/Multi Renderer",
-} as Story;
diff --git a/packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata.ts b/packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata.ts
deleted file mode 100644
index 0bb6ab4e6b..0000000000
--- a/packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import shapes from "../shapes";
-
-import type {Item} from "../item-types";
-import type {Shape} from "../shape-types";
-
-export const simpleQuestionShape: Shape = shapes.shape({
- blurb: shapes.content,
- question: shapes.content,
- hints: shapes.hints,
-});
-
-// Shape: simpleQuestionShape
-export const question1: Item = {
- _multi: {
- blurb: {
- __type: "content",
- content: "",
- images: {},
- widgets: {},
- },
- hints: [
- {
- __type: "hint",
- content:
- "If two triangles are congruent, then they have the same side lengths and angle measures.",
- images: {},
- replace: false,
- widgets: {},
- },
- {
- __type: "hint",
- content:
- "A triangle congruent to triangle $ABC$ must also have side lengths of $12$, $14$ and $20$.",
- images: {},
- replace: false,
- widgets: {},
- },
- {
- __type: "hint",
- content:
- "The following triangle is congruent to triangle $ABC$:\n\n* A triangle with side lengths of $12$, $14$, and $20$",
- images: {},
- replace: false,
- widgets: {},
- },
- ],
- question: {
- __type: "content",
- content:
- "Triangle $ABC$ has side lengths of $12$, $14$, and $20$. Which of the following triangles is congruent to triangle $ABC$ ?\n\n[[☃ radio 1]]\n\nEnter the number 3 into this field: [[☃ input-number 1]]",
- widgets: {
- "radio 1": {
- alignment: "default",
- graded: true,
- options: {
- choices: [
- {
- clue: "Congruent triangles have the same side lengths.",
- content:
- "A triangle with side lengths of $3$, $4$, and $5$",
- correct: false,
- },
- {
- clue: "Congruent triangles have the same side lengths.\n\nThis choice is similar to triangle $ABC$.",
- content:
- "A triangle with side lengths of $6$, $7$, and $10$",
- correct: false,
- },
- {
- clue: "Congruent triangles have the same side lengths.",
- content:
- "A triangle with side lengths of $10$, $12$, and $18$",
- correct: false,
- isNoneOfTheAbove: false,
- },
- {
- clue: "Congruent triangles have the same side lengths.",
- content:
- "A triangle with side lengths of $12$, $14$, and $20$",
- correct: true,
- isNoneOfTheAbove: false,
- },
- {
- clue: "Congruent triangles have the same side lengths.\n\nThis choice is similar to triangle $ABC$.",
- content:
- "A triangle with side lengths of $24$, $28$, and $40$",
- correct: false,
- isNoneOfTheAbove: false,
- },
- ],
- countChoices: false,
- deselectEnabled: false,
- displayCount: null,
- hasNoneOfTheAbove: false,
- multipleSelect: false,
- randomize: false,
- },
- static: false,
- type: "radio",
- version: {
- major: 1,
- minor: 0,
- },
- },
- "input-number 1": {
- type: "input-number",
- graded: true,
- options: {
- answerType: "number",
- value: "-42",
- simplify: "required",
- size: "normal",
- inexact: false,
- maxError: 0.1,
- },
- },
- },
- },
- },
-};
-
-const definitionWidgetConfig = {
- graded: true,
- version: {major: 0, minor: 0},
- static: false,
- type: "definition",
- options: {
- togglePrompt: "word",
- definition: "",
- static: false,
- },
- alignment: "default",
-} as const;
-
-// Shape: simpleQuestionShape
-export const definitionQuestion1: Item = {
- _multi: {
- blurb: {
- __type: "content",
- content: "Here's a blurb [[☃ definition 1]]",
- images: {},
- widgets: {
- "definition 1": definitionWidgetConfig,
- },
- },
- hints: [
- {
- __type: "hint",
- content: "Hint #1 [[☃ definition 2]]",
- images: {},
- replace: false,
- widgets: {
- "definition 2": definitionWidgetConfig,
- },
- },
- {
- __type: "hint",
- content: "Hint #1 [[☃ definition 3]]",
- images: {},
- replace: false,
- widgets: {
- "definition 3": definitionWidgetConfig,
- },
- },
- ],
- question: {
- __type: "content",
- content:
- "What is the answer to this?\n\n[[☃ definition 4]]\n\n[[☃ definition 5]]",
- widgets: {
- "definition 4": definitionWidgetConfig,
- "definition 5": definitionWidgetConfig,
- },
- },
- },
-};
diff --git a/packages/perseus/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap b/packages/perseus/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap
deleted file mode 100644
index 286f99fa5e..0000000000
--- a/packages/perseus/src/multi-items/__tests__/__snapshots__/multi-renderer.test.tsx.snap
+++ /dev/null
@@ -1,1181 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`multi-item renderer should snapshot: initial render 1`] = `
-
-
-
-
-
-
-
- Triangle
-
-
-
- ABC
-
-
-
- has side lengths of
-
-
-
- 12
-
-
-
- ,
-
-
-
- 14
-
-
-
- , and
-
-
-
- 20
-
-
-
- . Which of the following triangles is congruent to triangle
-
-
-
- ABC
-
-
-
- ?
-
-
-
-
-
- Enter the number 3 into this field:
-
-
-
-
-
-
-
-
-
- Hint #1
-
-
- 1 / 3
-
-
-
-
- If two triangles are congruent, then they have the same side lengths and angle measures.
-
-
-
-
-
-
-
-
-`;
diff --git a/packages/perseus/src/multi-items/__tests__/items.test.ts b/packages/perseus/src/multi-items/__tests__/items.test.ts
deleted file mode 100644
index 0206670540..0000000000
--- a/packages/perseus/src/multi-items/__tests__/items.test.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import {
- buildEmptyItemTreeForShape,
- buildEmptyItemForShape,
- findContentNodesInItem,
- findHintNodesInItem,
- inferItemShape,
- itemToTree,
- treeToItem,
-} from "../items";
-import shapes from "../shapes";
-
-import type {
- Item,
- ItemTree,
- ContentNode,
- HintNode,
- TagsNode,
- ItemArrayNode,
-} from "../item-types";
-import type {
- ContentShape,
- HintShape,
- TagsShape,
- ArrayShape,
- ObjectShape,
-} from "../shape-types";
-
-describe("treeToItem", () => {
- it("wraps an item tree in the `_multi` key", () => {
- const tree = {__type: "hint"} as const;
- expect({_multi: tree}).toEqual(treeToItem(tree));
- });
-});
-
-describe("itemToTree", () => {
- it("unwraps an item tree from the `_multi` key", () => {
- const tree = {__type: "hint"} as const;
- expect(tree).toEqual(itemToTree({_multi: tree}));
- });
-});
-
-describe("buildEmptyItemTreeForShape and buildEmptyItemForShape", () => {
- const expectedEmptyContentNode = {
- content: "",
- images: {},
- widgets: {},
- __type: "content",
- } as const;
-
- const expectedEmptyHintNode = {
- replace: false,
- content: "",
- images: {},
- widgets: {},
- __type: "hint",
- } as const;
-
- function assertEmptyItemTreeForShape(
- expectedEmptyTree: ItemTree,
- shape: ContentShape | HintShape | TagsShape | ArrayShape | ObjectShape,
- ) {
- const emptyTree = buildEmptyItemTreeForShape(shape);
- expect(emptyTree).toEqual(expectedEmptyTree);
-
- const expectedEmptyItem = treeToItem(expectedEmptyTree);
- const emptyItem = buildEmptyItemForShape(shape);
- expect(emptyItem).toEqual(expectedEmptyItem);
- }
-
- it("creates an empty item", () => {
- assertEmptyItemTreeForShape(expectedEmptyContentNode, shapes.content);
- });
-
- it("creates an empty hint", () => {
- assertEmptyItemTreeForShape(expectedEmptyHintNode, shapes.hint);
- });
-
- it("creates empty tags", () => {
- assertEmptyItemTreeForShape([] as TagsNode, shapes.tags);
- });
-
- it("creates an empty array", () => {
- assertEmptyItemTreeForShape(
- [] as ItemArrayNode,
- shapes.arrayOf(shapes.content),
- );
- });
-
- it("creates an empty object containing all node types", () => {
- const shape = shapes.shape({
- instructions: shapes.content,
- hint: shapes.hint,
- questions: shapes.arrayOf(shapes.content),
- context: shapes.shape({
- prompt: shapes.content,
- footnotes: shapes.content,
- }),
- });
- const expectedEmptyTree = {
- instructions: expectedEmptyContentNode,
- hint: expectedEmptyHintNode,
- questions: [] as ItemArrayNode,
- context: {
- prompt: expectedEmptyContentNode,
- footnotes: expectedEmptyContentNode,
- },
- } as const;
- assertEmptyItemTreeForShape(expectedEmptyTree, shape);
- });
-});
-
-describe("inferItemShape", () => {
- it("infers a content node's shape", () => {
- const item = buildEmptyItemForShape(shapes.content);
- expect(shapes.content).toEqual(inferItemShape(item));
- });
-
- // TODO(mdr): Remove #LegacyContentNode support.
- it("infers a legacy content node's shape", () => {
- const item = {
- _multi: {
- __type: "item",
- content: "",
- images: {},
- widgets: {},
- },
- } as const;
- expect(shapes.content).toEqual(inferItemShape(item));
- });
-
- it("infers a hint node's shape", () => {
- const item = buildEmptyItemForShape(shapes.hint);
- expect(shapes.hint).toEqual(inferItemShape(item));
- });
-
- it("infers a tags node's shape", () => {
- const item = treeToItem(["foo", "bar"] as TagsNode);
- expect(shapes.tags).toEqual(inferItemShape(item));
- });
-
- it("infers an object node's shape", () => {
- const shape = shapes.shape({
- content: shapes.content,
- hint: shapes.hint,
- });
- const item = buildEmptyItemForShape(shape);
- expect(shape).toEqual(inferItemShape(item));
- });
-
- it("poorly infers an empty array node's shape", () => {
- const item = treeToItem([] as ItemArrayNode);
- expect(shapes.arrayOf(shapes.content)).toEqual(inferItemShape(item));
- });
-
- it("correctly infers an nonempty single-typed array node's shape", () => {
- const item = treeToItem([
- /**
- * TODO(somewhatabstract, JIRA-XXXX):
- * The Tree types are really hard to work with properly.
- */ buildEmptyItemTreeForShape(shapes.hint),
- buildEmptyItemTreeForShape(shapes.hint),
- buildEmptyItemTreeForShape(shapes.hint),
- ] as ItemArrayNode);
- expect(shapes.arrayOf(shapes.hint)).toEqual(inferItemShape(item));
- });
-
- it("poorly infers an invalid multi-type array node's shape", () => {
- const item = treeToItem([
- /**
- * TODO(somewhatabstract, JIRA-XXXX):
- * The Tree types are really hard to work with properly.
- */ buildEmptyItemTreeForShape(shapes.hint),
- buildEmptyItemTreeForShape(shapes.content),
- buildEmptyItemTreeForShape(shapes.hint),
- ] as ItemArrayNode);
- expect(shapes.arrayOf(shapes.hint)).toEqual(inferItemShape(item));
- });
-});
-
-function content(n: number): ContentNode {
- return {
- __type: "content",
- content: `content ${n}`,
- };
-}
-
-function hint(n: number): HintNode {
- return {
- __type: "hint",
- content: `hint ${n}`,
- };
-}
-
-const shape = shapes.shape({
- a: shapes.content,
- b: shapes.arrayOf(shapes.content),
- c: shapes.shape({
- d: shapes.content,
- e: shapes.hint,
- }),
- f: shapes.hint,
-});
-
-const item: Item = treeToItem({
- a: content(1),
- /**
- * TODO(somewhatabstract, JIRA-XXXX):
- * The Tree types are really hard to work with properly.
- */ b: [content(2), content(3), content(4)] as ItemArrayNode,
- c: {
- d: content(5),
- e: hint(6),
- },
- f: hint(7),
-});
-
-describe("findContentNodesInItem", () => {
- it("calls the callback for each content node in the item", () => {
- const contents = [];
- // @ts-expect-error - TS2345 - Argument of type 'ContentNode' is not assignable to parameter of type 'never'.
- findContentNodesInItem(item, shape, (c) => contents.push(c));
- contents.sort();
- expect([
- content(1),
- content(2),
- content(3),
- content(4),
- content(5),
- ]).toEqual(contents);
- });
-});
-
-describe("findHintNodesInItem", () => {
- it("calls the callback for each hint node in the item", () => {
- const hints: Array = [];
- findHintNodesInItem(item, shape, (c) => hints.push(c));
- hints.sort();
- expect([hint(6), hint(7)]).toEqual(hints);
- });
-});
diff --git a/packages/perseus/src/multi-items/__tests__/multi-renderer.test.tsx b/packages/perseus/src/multi-items/__tests__/multi-renderer.test.tsx
deleted file mode 100644
index 11bd727b6c..0000000000
--- a/packages/perseus/src/multi-items/__tests__/multi-renderer.test.tsx
+++ /dev/null
@@ -1,906 +0,0 @@
-import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
-import {act, render, screen} from "@testing-library/react";
-import {userEvent as userEventLib} from "@testing-library/user-event";
-import * as React from "react";
-
-import {
- testDependencies,
- testDependenciesV2,
-} from "../../../../../testing/test-dependencies";
-import * as Dependencies from "../../dependencies";
-import {registerAllWidgetsForTesting} from "../../util/register-all-widgets-for-testing";
-import {
- definitionQuestion1,
- question1,
- simpleQuestionShape,
-} from "../__testdata__/multi-renderer.testdata";
-import MultiRenderer from "../multi-renderer";
-import shapes from "../shapes";
-
-import type {FilterCriterion, Widget} from "../../types";
-import type {Item} from "../item-types";
-import type {Tree} from "../tree-types";
-import type {UserEvent} from "@testing-library/user-event";
-
-// A little helper used in the render callback of a MultiRenderer.
-type Props = {renderers: any};
-const SimpleLayout = ({renderers}: Props): React.ReactElement => {
- if (renderers == null) {
- throw new Error("renderers was null");
- }
-
- const {blurb, question, hints} = renderers;
- return (
-
-
{blurb}
-
{question}
-
{hints?.firstN(1)}
-
- );
-};
-
-// Note that the `question` passed in _must_ be of the simpleQuestionShape
-// shape.
-const renderSimpleQuestion = (question: Item) => {
- // Arrange
- let renderer: MultiRenderer | null | undefined = null;
- const result = render(
-
- (renderer = r)}
- dependencies={testDependenciesV2}
- >
- {({renderers}) => }
-
- ,
- );
- if (renderer == null) {
- throw new Error("Rendering failed.");
- }
-
- return {
- ...result,
- renderer,
- };
-};
-
-// A test helper to find widgets in a MultiRenderer.
-const _findWidgets = (
- renderer: MultiRenderer,
- filterCriterion: FilterCriterion,
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic.
-): Tree<
- ReadonlyArray,
- ReadonlyArray,
- null
-> => {
- return renderer._mapRenderers((data) => {
- if (data.ref == null) {
- return [];
- }
-
- // Note we use findInternalWidgets() here instead of
- // _findWidgets(). Otherwise we get the same widget appearing in
- // multiple places in the result tree.
- return data.ref.findInternalWidgets(filterCriterion);
- });
-};
-
-describe("multi-item renderer", () => {
- let userEvent: UserEvent;
- beforeEach(() => {
- userEvent = userEventLib.setup({
- advanceTimers: jest.advanceTimersByTime,
- });
-
- jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
- testDependencies,
- );
- registerAllWidgetsForTesting();
- });
-
- it("should snapshot", () => {
- // Arrange and Act
- const {container} = renderSimpleQuestion(question1);
-
- // Assert
- expect(container).toMatchSnapshot("initial render");
- });
-
- it("should render the error if one occurs (item doesn't match shape)", () => {
- // Arrange
- // We mock out `console.error` because the we're explicitly
- // triggering and error, but we don't want that to fail the test.
- jest.spyOn(console, "error").mockImplementation(() => {});
-
- // Act
- render(
-
-
- {({renderers}) => {
- return
;
- }}
-
- ,
- );
-
- // Assert
- expect(screen.getByText(/Error rendering:/)).toBeInTheDocument();
- });
-
- describe("state serialization", () => {
- it("should return serialized state from all items", async () => {
- // Arrange
- const {renderer} = renderSimpleQuestion(question1);
-
- // Nudge the widget to a non-default state (ie. an item is
- // selected, a value is entered). You can see the result of this in the `choiceStates`
- // array in the captured state below where the choice at index 2 has
- // `"selected": true` (instead of false) and the input-number has a `currentValue`.
- await userEvent.click(screen.getAllByRole("radio")[2]); // Correct
- await userEvent.type(screen.getByRole("textbox"), "+42"); // Correct
-
- // Act
- // @ts-expect-error - TS2339 - Property '_getSerializedState' does not exist on type 'never'.
- const state = renderer._getSerializedState(null);
-
- // Assert
- expect(state).toMatchInlineSnapshot(`
- {
- "blurb": {},
- "hints": [
- null,
- null,
- null,
- ],
- "question": {
- "input-number 1": {
- "answerType": "number",
- "currentValue": "+42",
- "rightAlign": undefined,
- "simplify": "required",
- "size": "normal",
- },
- "radio 1": {
- "choiceStates": [
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": true,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- ],
- "choices": [
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $3$, $4$, and $5$",
- "correct": false,
- "originalIndex": 0,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $6$, $7$, and $10$",
- "correct": false,
- "originalIndex": 1,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $10$, $12$, and $18$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 2,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $12$, $14$, and $20$",
- "correct": true,
- "isNoneOfTheAbove": false,
- "originalIndex": 3,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $24$, $28$, and $40$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 4,
- },
- ],
- "countChoices": false,
- "deselectEnabled": false,
- "hasNoneOfTheAbove": false,
- "multipleSelect": false,
- "numCorrect": 1,
- "selectedChoices": [
- false,
- false,
- false,
- true,
- false,
- ],
- },
- },
- }
- `);
- });
-
- it("should return values from lastSerializedState if ref's getSerializedState returns null", async () => {
- // Arrange
- const {renderer} = renderSimpleQuestion(question1);
-
- await userEvent.click(screen.getAllByRole("radio")[2]);
- await userEvent.type(screen.getByRole("textbox"), "99");
-
- // Act
- // @ts-expect-error - TS2339 - Property '_getSerializedState' does not exist on type 'never'.
- const state = renderer._getSerializedState({
- blurb: "last blurb",
- hints: ["uno", "dos" /* intentionally not passing a third */],
- });
-
- // Assert
- expect(state).toMatchInlineSnapshot(`
- {
- "blurb": {},
- "hints": [
- "uno",
- "dos",
- undefined,
- ],
- "question": {
- "input-number 1": {
- "answerType": "number",
- "currentValue": "99",
- "rightAlign": undefined,
- "simplify": "required",
- "size": "normal",
- },
- "radio 1": {
- "choiceStates": [
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": true,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- ],
- "choices": [
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $3$, $4$, and $5$",
- "correct": false,
- "originalIndex": 0,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $6$, $7$, and $10$",
- "correct": false,
- "originalIndex": 1,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $10$, $12$, and $18$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 2,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $12$, $14$, and $20$",
- "correct": true,
- "isNoneOfTheAbove": false,
- "originalIndex": 3,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $24$, $28$, and $40$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 4,
- },
- ],
- "countChoices": false,
- "deselectEnabled": false,
- "hasNoneOfTheAbove": false,
- "multipleSelect": false,
- "numCorrect": 1,
- "selectedChoices": [
- false,
- false,
- false,
- true,
- false,
- ],
- },
- },
- }
- `);
- });
-
- it("should restore serialized state to each widget", () => {
- // Arrange
- const {renderer} = renderSimpleQuestion(question1);
-
- // This state was built by getting the renderer into this state
- // using `userEvent` calls and then calling `getSerializedState()`
- // on the renderer.
- const state = {
- blurb: {},
- hints: [null, null, null],
- question: {
- "input-number 1": {
- answerType: "number",
- currentValue: "+42",
- rightAlign: false,
- simplify: "required",
- size: "normal",
- },
- "radio 1": {
- choiceStates: [
- {
- correctnessShown: false,
- crossedOut: false,
- highlighted: false,
- previouslyAnswered: false,
- rationaleShown: false,
- readOnly: false,
- selected: false,
- },
- {
- correctnessShown: false,
- crossedOut: false,
- highlighted: false,
- previouslyAnswered: false,
- rationaleShown: false,
- readOnly: false,
- selected: false,
- },
- {
- correctnessShown: false,
- crossedOut: false,
- highlighted: false,
- previouslyAnswered: false,
- rationaleShown: false,
- readOnly: false,
- selected: true,
- },
- {
- correctnessShown: false,
- crossedOut: false,
- highlighted: false,
- previouslyAnswered: false,
- rationaleShown: false,
- readOnly: false,
- selected: false,
- },
- {
- correctnessShown: false,
- crossedOut: false,
- highlighted: false,
- previouslyAnswered: false,
- rationaleShown: false,
- readOnly: false,
- selected: false,
- },
- ],
- choices: [
- {
- clue: "Congruent triangles have the same side lengths.",
- content:
- "A triangle with side lengths of $3$, $4$, and $5$",
- correct: false,
- originalIndex: 0,
- },
- {
- clue:
- "Congruent triangles have the same side lengths.\n" +
- "\n" +
- "This choice is similar to triangle $ABC$.",
- content:
- "A triangle with side lengths of $6$, $7$, and $10$",
- correct: false,
- originalIndex: 1,
- },
- {
- clue: "Congruent triangles have the same side lengths.",
- content:
- "A triangle with side lengths of $10$, $12$, and $18$",
- correct: false,
- isNoneOfTheAbove: false,
- originalIndex: 2,
- },
- {
- clue: "Congruent triangles have the same side lengths.",
- content:
- "A triangle with side lengths of $12$, $14$, and $20$",
- correct: true,
- isNoneOfTheAbove: false,
- originalIndex: 3,
- },
- {
- clue:
- "Congruent triangles have the same side lengths.\n" +
- "\n" +
- "This choice is similar to triangle $ABC$.",
- content:
- "A triangle with side lengths of $24$, $28$, and $40$",
- correct: false,
- isNoneOfTheAbove: false,
- originalIndex: 4,
- },
- ],
- countChoices: false,
- deselectEnabled: false,
- hasNoneOfTheAbove: false,
- multipleSelect: false,
- numCorrect: 1,
- selectedChoices: [false, false, false, true, false],
- },
- },
- } as const;
-
- // Act
- // @ts-expect-error - TS2339 - Property 'restoreSerializedState' does not exist on type 'never'.
- act(() => renderer.restoreSerializedState(state));
-
- // Assert
- expect(screen.getAllByRole("radio")[0]).not.toBeChecked();
- expect(screen.getAllByRole("radio")[1]).not.toBeChecked();
- expect(screen.getAllByRole("radio")[2]).toBeChecked();
- expect(screen.getAllByRole("radio")[3]).not.toBeChecked();
- expect(screen.getAllByRole("radio")[4]).not.toBeChecked();
- expect(screen.getByRole("textbox")).toHaveValue("+42");
- });
-
- it("should call callback when restore serialized state is complete", () => {
- // Arrange
- const {renderer} = renderSimpleQuestion(definitionQuestion1);
- const callback = jest.fn();
-
- // Act
- act(() =>
- // @ts-expect-error - TS2339 - Property 'restoreSerializedState' does not exist on type 'never'.
- renderer.restoreSerializedState(
- {
- blurb: {"definition 1": 1},
- hints: [2, 3],
- question: {
- "definition 4": 4,
- "definition 5": 5,
- },
- },
- callback,
- ),
- );
- act(() => jest.runOnlyPendingTimers());
-
- // Assert
- expect(callback).toHaveBeenCalled();
- });
- });
-
- it("should find widgets in other parts of the tree", () => {
- // Arrange
- const {renderer} = renderSimpleQuestion(definitionQuestion1);
- // Grab "definition 5" out of the render tree. Note that the result
- // here is a tree of the same shape as the `mockedQuestion1`'s shape.
- // Each node in the tree is a `$ReadOnlyArray`.
- const result = _findWidgets(renderer, (id) => id === "definition 5");
-
- // Act
- // We're using the API provided to the widget we found ("definition
- // 5") to look into other parts of the render tree for another widget
- // ("definition 1"). This verifies that the MultiRenderer has
- // correctly passed down a functioning `findWidgets` prop to the
- // widgets.
- // Oh the TypeScript warnings we'll suppress. Welcome to multi items.
- const widget1 = result.question[0].props.findWidgets("definition 1");
-
- // Assert
- expect(widget1).not.toBeNull();
- });
-
- it("should return the scores in the shape of the tree", async () => {
- // Arrange
- const {renderer} = renderSimpleQuestion(question1);
-
- await userEvent.click(screen.getAllByRole("radio")[3]); // Correct
- await userEvent.type(screen.getByRole("textbox"), "-42"); // Correct
-
- // Act
- // @ts-expect-error - TS2339 - Property 'getScores' does not exist on type 'never'.
- const score = renderer.getScores();
-
- // Assert
- expect(score).toMatchInlineSnapshot(`
- {
- "blurb": {
- "correct": true,
- "empty": false,
- "guess": [],
- "message": null,
- "state": {},
- },
- "hints": [
- null,
- null,
- null,
- ],
- "question": {
- "correct": true,
- "empty": false,
- "guess": [
- {
- "choicesSelected": [
- false,
- false,
- false,
- true,
- false,
- ],
- },
- {
- "currentValue": "-42",
- },
- ],
- "message": null,
- "state": {
- "input-number 1": {
- "answerType": "number",
- "currentValue": "-42",
- "rightAlign": undefined,
- "simplify": "required",
- "size": "normal",
- },
- "radio 1": {
- "choiceStates": [
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": true,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- ],
- "choices": [
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $3$, $4$, and $5$",
- "correct": false,
- "originalIndex": 0,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $6$, $7$, and $10$",
- "correct": false,
- "originalIndex": 1,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $10$, $12$, and $18$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 2,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $12$, $14$, and $20$",
- "correct": true,
- "isNoneOfTheAbove": false,
- "originalIndex": 3,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $24$, $28$, and $40$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 4,
- },
- ],
- "countChoices": false,
- "deselectEnabled": false,
- "hasNoneOfTheAbove": false,
- "multipleSelect": false,
- "numCorrect": 1,
- "selectedChoices": [
- false,
- false,
- false,
- true,
- false,
- ],
- },
- },
- },
- }
- `);
- });
-
- it("should return the composite score for the whole tree", async () => {
- // Arrange
- const {renderer} = renderSimpleQuestion(question1);
-
- await userEvent.click(screen.getAllByRole("radio")[3]); // Correct
- await userEvent.type(screen.getByRole("textbox"), "-42"); // Correct
-
- // Act
- // @ts-expect-error - TS2339 - Property 'score' does not exist on type 'never'.
- const score = renderer.score();
-
- // Assert
- expect(score).toMatchInlineSnapshot(`
- {
- "correct": true,
- "empty": false,
- "guess": {
- "blurb": [],
- "hints": [
- null,
- null,
- null,
- ],
- "question": [
- {
- "choicesSelected": [
- false,
- false,
- false,
- true,
- false,
- ],
- },
- {
- "currentValue": "-42",
- },
- ],
- },
- "message": null,
- "state": [
- {},
- {
- "input-number 1": {
- "answerType": "number",
- "currentValue": "-42",
- "rightAlign": undefined,
- "simplify": "required",
- "size": "normal",
- },
- "radio 1": {
- "choiceStates": [
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": true,
- },
- {
- "correctnessShown": false,
- "crossedOut": false,
- "highlighted": false,
- "previouslyAnswered": false,
- "rationaleShown": false,
- "readOnly": false,
- "selected": false,
- },
- ],
- "choices": [
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $3$, $4$, and $5$",
- "correct": false,
- "originalIndex": 0,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $6$, $7$, and $10$",
- "correct": false,
- "originalIndex": 1,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $10$, $12$, and $18$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 2,
- },
- {
- "clue": "Congruent triangles have the same side lengths.",
- "content": "A triangle with side lengths of $12$, $14$, and $20$",
- "correct": true,
- "isNoneOfTheAbove": false,
- "originalIndex": 3,
- },
- {
- "clue": "Congruent triangles have the same side lengths.
-
- This choice is similar to triangle $ABC$.",
- "content": "A triangle with side lengths of $24$, $28$, and $40$",
- "correct": false,
- "isNoneOfTheAbove": false,
- "originalIndex": 4,
- },
- ],
- "countChoices": false,
- "deselectEnabled": false,
- "hasNoneOfTheAbove": false,
- "multipleSelect": false,
- "numCorrect": 1,
- "selectedChoices": [
- false,
- false,
- false,
- true,
- false,
- ],
- },
- },
- ],
- }
- `);
- });
-});
diff --git a/packages/perseus/src/multi-items/__tests__/prop-type-builders.test.ts b/packages/perseus/src/multi-items/__tests__/prop-type-builders.test.ts
deleted file mode 100644
index 921d0efa54..0000000000
--- a/packages/perseus/src/multi-items/__tests__/prop-type-builders.test.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import {treeToItem} from "../items";
-import {buildPropTypeForShape} from "../prop-type-builders";
-import shapes from "../shapes";
-
-// TODO(emily): [PERSEUS_MERGE] Calling prop types as a function fails these
-// tests. Rewrite them once we're using the prop-types library.
-describe.skip("buildPropTypeForShape", () => {
- // The value we're wrapping might not be a valid ItemTree - that's the
- // point of testing this PropType - so we disable type checking by casting
- // `value` to type `any`. The current implementation of `treeToItem` can
- // handle that possibility.
- const tryPropType = (propType: any, value: any) =>
- propType({value: treeToItem(value)}, "value", "");
-
- const assertPropTypePasses = (propType, value) =>
- expect(null).toEqual(tryPropType(propType, value));
-
- const assertPropTypeFails = (propType, value) =>
- expect(tryPropType(propType, value) instanceof Error).toBeTruthy();
-
- it("validates a content node", () => {
- const propType = buildPropTypeForShape(shapes.content);
-
- // Perseus has default values for all item fields, so all except the
- // type are optional.
- assertPropTypePasses(propType, {__type: "content"});
- assertPropTypePasses(propType, {
- content: "",
- widgets: {},
- images: {},
- __type: "content",
- });
-
- // We also leave the full object optional by default, like the propType
- // primitives.
- assertPropTypePasses(propType, null);
-
- // But specifying a bad type for any field will fail the propType.
- assertPropTypeFails(propType, {content: 1, __type: "content"});
- assertPropTypeFails(propType, {widgets: 1, __type: "content"});
- assertPropTypeFails(propType, {images: 1, __type: "content"});
- });
-
- // TODO(mdr): Remove #LegacyContentNode support.
- it("validates a legacy content node", () => {
- const propType = buildPropTypeForShape(shapes.content);
-
- // Perseus has default values for all item fields, so all except the
- // type are optional.
- assertPropTypePasses(propType, {__type: "item"});
- assertPropTypePasses(propType, {
- content: "",
- widgets: {},
- images: {},
- __type: "item",
- });
-
- // We also leave the full object optional by default, like the propType
- // primitives.
- assertPropTypePasses(propType, null);
-
- // But specifying a bad type for any field will fail the propType.
- assertPropTypeFails(propType, {content: 1, __type: "item"});
- assertPropTypeFails(propType, {widgets: 1, __type: "item"});
- assertPropTypeFails(propType, {images: 1, __type: "item"});
- });
-
- it("validates a hint node", () => {
- const propType = buildPropTypeForShape(shapes.hint);
-
- // Perseus has default values for all hint fields, so all except the
- // type are optional.
- assertPropTypePasses(propType, {__type: "hint"});
- assertPropTypePasses(propType, {
- content: "",
- widgets: {},
- images: {},
- replace: false,
- __type: "hint",
- });
-
- // We also leave the full object optional by default, like the propType
- // primitives.
- assertPropTypePasses(propType, null);
-
- // But specifying a bad type for any field will fail the propType.
- assertPropTypeFails(propType, {content: 1, __type: "hint"});
- assertPropTypeFails(propType, {widgets: 1, __type: "hint"});
- assertPropTypeFails(propType, {images: 1, __type: "hint"});
- assertPropTypeFails(propType, {replace: 1, __type: "hint"});
- });
-
- it("validates an array", () => {
- const propType = buildPropTypeForShape(shapes.arrayOf(shapes.content));
- const emptyItem = {__type: "content"} as const;
-
- assertPropTypePasses(propType, []);
- assertPropTypePasses(propType, [emptyItem, emptyItem, emptyItem]);
-
- // While the array itself is optional, its elements are required.
- assertPropTypePasses(propType, null);
- assertPropTypeFails(propType, [emptyItem, null, emptyItem]);
- });
-
- it("validates tags", () => {
- const propType = buildPropTypeForShape(shapes.tags);
-
- assertPropTypePasses(propType, []);
- assertPropTypePasses(propType, ["a", "b", "c"]);
-
- assertPropTypePasses(propType, null);
- assertPropTypeFails(propType, ["a", null, "b"]);
- assertPropTypeFails(propType, [1, 2, 3]);
- });
-
- it("validates an object", () => {
- const propType = buildPropTypeForShape(
- shapes.shape({
- a: shapes.content,
- b: shapes.content,
- }),
- );
- const emptyItem = {__type: "content"} as const;
-
- assertPropTypePasses(propType, {a: emptyItem, b: emptyItem});
-
- // While the object itself is optional, its fields are required.
- assertPropTypePasses(propType, null);
- assertPropTypeFails(propType, {});
- assertPropTypeFails(propType, {a: emptyItem});
- assertPropTypeFails(propType, {b: emptyItem});
- });
-});
diff --git a/packages/perseus/src/multi-items/__tests__/shapes.test.ts b/packages/perseus/src/multi-items/__tests__/shapes.test.ts
deleted file mode 100644
index f359f45cdf..0000000000
--- a/packages/perseus/src/multi-items/__tests__/shapes.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import shapes from "../shapes";
-
-describe("shapes.content", () => {
- it('has type "content"', () => {
- const shape = shapes.content;
- expect("content").toEqual(shape.type);
- });
-});
-
-describe("shapes.hint", () => {
- it('has type "hint"', () => {
- const shape = shapes.hint;
- expect("hint").toEqual(shape.type);
- });
-});
-
-describe("shapes.arrayOf", () => {
- it('has type "array"', () => {
- const shape = shapes.arrayOf(shapes.content);
- expect("array").toEqual(shape.type);
- });
-
- it("has the given shape as its `elementShape` property", () => {
- const shape = shapes.arrayOf(shapes.content);
- expect(shapes.content).toEqual(shape.elementShape);
- });
-});
-
-describe("shapes.shape", () => {
- it('has type "object"', () => {
- const shape = shapes.shape({});
- expect("object").toEqual(shape.type);
- });
-
- it("has the given shapes as its `shape` property", () => {
- const shape = shapes.shape({
- foo: shapes.content,
- bar: shapes.hint,
- });
- expect(shapes.content).toEqual(shape.shape.foo);
- expect(shapes.hint).toEqual(shape.shape.bar);
- });
-});
diff --git a/packages/perseus/src/multi-items/__tests__/trees.test.ts b/packages/perseus/src/multi-items/__tests__/trees.test.ts
deleted file mode 100644
index 96b2b6dfaa..0000000000
--- a/packages/perseus/src/multi-items/__tests__/trees.test.ts
+++ /dev/null
@@ -1,317 +0,0 @@
-import shapes from "../shapes";
-import {buildMapper} from "../trees";
-
-import type {
- ItemTree,
- ContentNode,
- HintNode,
- TagsNode,
- ItemArrayNode,
-} from "../item-types";
-import type {TreeMapper} from "../trees";
-
-describe("buildMapper", () => {
- function content(n): ContentNode {
- return {
- __type: "content",
- content: `content ${n}`,
- };
- }
-
- function hint(n: number): HintNode {
- return {
- __type: "hint",
- content: `hint ${n}`,
- };
- }
-
- function tags(...elements): TagsNode {
- // This function mostly exists as a shorthand way to clarify to TypeScript
- // that this is a TagsNode, not a confused ItemArrayNode.
- return elements;
- }
-
- function array(...elements): ItemArrayNode {
- // This function mostly exists as a shorthand way to clarify to TypeScript
- // that this is an ItemArrayNode, not a confused TagsNode.
- /**
- * TODO(somewhatabstract, JIRA-XXXX):
- * The Tree types are really hard to work with properly.
- */
- return elements;
- }
-
- const shape = shapes.shape({
- a: shapes.content,
- b: shapes.arrayOf(shapes.content),
- c: shapes.shape({
- d: shapes.content,
- e: shapes.hint,
- }),
- f: shapes.hint,
- g: shapes.tags,
- });
-
- const tree: ItemTree = {
- a: content(1),
- b: array(content(2), content(3), content(4)),
- c: {
- d: content(5),
- e: hint(6),
- },
- f: hint(7),
- g: tags("foo", "bar"),
- };
-
- it("calls the content mapper for each of the content nodes", () => {
- const calledWith = [];
- buildMapper()
- // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'never'.
- .setContentMapper((c) => calledWith.push(c))
- .mapTree(tree, shape);
- calledWith.sort();
-
- expect([
- content(1),
- content(2),
- content(3),
- content(4),
- content(5),
- ]).toEqual(calledWith);
- });
-
- it("calls the hint mapper for each of the hint nodes", () => {
- const calledWith = [];
- buildMapper()
- // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'never'.
- .setHintMapper((h) => calledWith.push(h))
- .mapTree(tree, shape);
- calledWith.sort();
-
- expect([hint(6), hint(7)]).toEqual(calledWith);
- });
-
- it("calls the tags mapper for each of the tags nodes", () => {
- const calledWith: Array = [];
- buildMapper()
- .setTagsMapper((t) => calledWith.push(t))
- .mapTree(tree, shape);
- calledWith.sort();
-
- expect([["foo", "bar"]]).toEqual(calledWith);
- });
-
- it("returns a mapped tree with the correct shape", () => {
- const mapper: TreeMapper<
- ContentNode,
- string,
- HintNode,
- string,
- TagsNode,
- string
- > = buildMapper()
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setContentMapper((c) => `mapped content: ${c.content || ""}`)
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setHintMapper((h) => `mapped hint: ${h.content || ""}`)
- .setTagsMapper((t) => `mapped tags: ${t.join(", ")}`);
- const result = mapper.mapTree(tree, shape);
-
- expect({
- a: "mapped content: content 1",
- b: [
- "mapped content: content 2",
- "mapped content: content 3",
- "mapped content: content 4",
- ],
- c: {
- d: "mapped content: content 5",
- e: "mapped hint: hint 6",
- },
- f: "mapped hint: hint 7",
- g: "mapped tags: foo, bar",
- }).toEqual(result);
- });
-
- it("provides each node shape to the leaf mappers", () => {
- // Return the type of the shape of the node in place of each node.
- const result = buildMapper()
- .setContentMapper((_, s) => s.type)
- .setHintMapper((_, s) => s.type)
- .setTagsMapper((_, s) => s.type)
- .mapTree(tree, shape);
-
- expect({
- a: "content",
- b: ["content", "content", "content"],
- c: {
- d: "content",
- e: "hint",
- },
- f: "hint",
- g: "tags",
- }).toEqual(result);
- });
-
- it("provides each node path to the leaf mappers", () => {
- // Return the path to the node in place of each node.
- const result = buildMapper()
- .setContentMapper((_, __, p) => p)
- .setHintMapper((_, __, p) => p)
- .setTagsMapper((_, __, p) => p)
- .mapTree(tree, shape);
-
- expect({
- a: ["a"],
- b: [
- ["b", 0],
- ["b", 1],
- ["b", 2],
- ],
- c: {
- d: ["c", "d"],
- e: ["c", "e"],
- },
- f: ["f"],
- g: ["g"],
- }).toEqual(result);
- });
-
- it("handles recursive objects", () => {
- const shape = shapes.shape({
- a: shapes.shape({
- b: shapes.shape({
- c: shapes.content,
- }),
- }),
- });
-
- const tree = {a: {b: {c: content(1)}}} as const;
-
- // @ts-expect-error - TS2322 - Type 'TreeMapperJustForLeaves' is not assignable to type 'TreeMapper'.
- const mapper: TreeMapper<
- ContentNode,
- string | null | undefined,
- HintNode,
- HintNode,
- TagsNode,
- TagsNode
- > = buildMapper().setContentMapper((c) => c.content);
- const result = mapper.mapTree(tree, shape);
-
- expect({a: {b: {c: "content 1"}}}).toEqual(result);
- });
-
- it("handles recursive arrays", () => {
- const shape = shapes.arrayOf(
- shapes.arrayOf(shapes.arrayOf(shapes.content)),
- );
-
- const tree = array(
- array(array(content(0)), array(content(1))),
- array(array(content(2)), array(content(3), content(4))),
- );
-
- // @ts-expect-error - TS2322 - Type 'TreeMapperJustForLeaves' is not assignable to type 'TreeMapper'.
- const mapper: TreeMapper<
- ContentNode,
- string | null | undefined,
- HintNode,
- HintNode,
- TagsNode,
- TagsNode
- > = buildMapper().setContentMapper((c) => c.content);
- const result = mapper.mapTree(tree, shape);
-
- expect([
- [["content 0"], ["content 1"]],
- [["content 2"], ["content 3", "content 4"]],
- ]).toEqual(result);
- });
-
- it("handles empty arrays", () => {
- const shape = shapes.arrayOf(shapes.arrayOf(shapes.content));
-
- const tree = array(array(), array(content(1)), array());
-
- // @ts-expect-error - TS2322 - Type 'TreeMapperJustForLeaves' is not assignable to type 'TreeMapper'.
- const mapper: TreeMapper<
- ContentNode,
- string | null | undefined,
- HintNode,
- HintNode,
- TagsNode,
- TagsNode
- > = buildMapper().setContentMapper((c) => c.content);
-
- // Test the outer array being empty.
- expect([]).toEqual(mapper.mapTree(array(), shape));
-
- // Test inner arrays being empty.
- const result = mapper.mapTree(tree, shape);
- expect([[], ["content 1"], []]).toEqual(result);
- });
-
- it("calls the array mapper for arrays", () => {
- const shape = shapes.arrayOf(shapes.content);
- const tree = array(content(1), content(2), content(3));
-
- let wasCalled = false;
- let callArgs: Record = {};
- // @ts-expect-error - TS2322 - Type 'TreeMapperForLeavesAndCollections' is not assignable to type 'TreeMapper'.
- const mapper: TreeMapper<
- ContentNode,
- string | null | undefined,
- HintNode,
- HintNode,
- TagsNode,
- TagsNode
- > = buildMapper()
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setContentMapper((c) => c.content)
- .setArrayMapper((mappedArray, originalArray, shape, path) => {
- wasCalled = true;
- callArgs = {mappedArray, originalArray, shape, path};
- return mappedArray;
- });
- mapper.mapTree(tree, shape);
-
- expect(wasCalled).toBeTruthy();
-
- expect(["content 1", "content 2", "content 3"]).toEqual(
- callArgs.mappedArray,
- );
- expect(tree).toEqual(callArgs.originalArray);
- expect(shape).toEqual(callArgs.shape);
- expect([]).toEqual(callArgs.path);
- });
-
- it("uses the array mapper return value to construct the new tree", () => {
- const mapper = buildMapper()
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setContentMapper((c) => c.content)
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setHintMapper((h) => h.content)
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setTagsMapper((t) => t.join(", "))
- .setArrayMapper((mappedArray, originalArray, shape, path) => {
- return mappedArray.map((child) => `${String(child)} in array`);
- });
- const result = mapper.mapTree(tree, shape);
-
- expect({
- a: "content 1",
- b: [
- "content 2 in array",
- "content 3 in array",
- "content 4 in array",
- ],
- c: {
- d: "content 5",
- e: "hint 6",
- },
- f: "hint 7",
- g: "foo, bar",
- }).toEqual(result);
- });
-});
diff --git a/packages/perseus/src/multi-items/item-types.ts b/packages/perseus/src/multi-items/item-types.ts
deleted file mode 100644
index 3e1ba29066..0000000000
--- a/packages/perseus/src/multi-items/item-types.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * Type definitions for multi-item types, including:
- *
- * - Item: A multi-item tree wrapped in a `_multi` key, to help us recognize it
- * as a multi-item in other contexts and avoid misinterpreting its
- * other properties.
- * - ItemTree: A multi-item without the `_multi` key. Conforms to the Tree
- * interface, so it's compatible with our tree traversal functions.
- * - And the various types of nodes that compose a tree.
- */
-import type {Tree, ArrayNode, ObjectNode} from "./tree-types";
-import type {PerseusWidgetsMap} from "../perseus-types";
-import type {ImageDict} from "../types";
-
-export type ContentNode = {
- // TODO(mdr): When we first drafted the multi-item feature, we named
- // content nodes "item" nodes, and later decided the term was
- // ambiguous and switched to "content". But we're temporarily keeping
- // support for the "item" string when inferring item shape, so that we
- // don't crash on multi-items we've already created - but all new
- // content nodes will be generated with the "content" string.
- //
- // Code blocks that enable this legacy support are greppable with the
- // keyword #LegacyContentNode.
- __type: "content" | "item";
- // Perseus has default values for these fields, so they're all optional.
- content?: string | null | undefined;
- images?: ImageDict | null | undefined;
- widgets?: PerseusWidgetsMap | null | undefined;
-};
-export type HintNode = {
- __type: "hint";
- // Perseus has default values for these fields, so they're all optional.
- content?: string | null | undefined;
- images?: ImageDict | null | undefined;
- widgets?: PerseusWidgetsMap | null | undefined;
- replace?: boolean | null | undefined;
-};
-export type TagsNode = ReadonlyArray;
-
-// @ts-expect-error - TS2315 - Type 'ArrayNode' is not generic.
-export type ItemArrayNode = ArrayNode;
-export type ItemObjectNode = ObjectNode;
-// @ts-expect-error - TS2315 - Type 'Tree' is not generic.
-export type ItemTree = Tree;
-
-// TODO(jeremy): I think we could refine this root type for multi items. Right
-// now a _multi's value can be _anything_ including "primitive" node types such
-// as "type: content" or "type: hint". That doesn't seem to match the shapes
-// that are in use to date. We could also move to a strict type at that point
-// because it appears that we _never_ add other root keys to an item that
-// specifies the `_multi` key. I think we'd be safe to restrict this root
-// object type to something like the following:
-// export type Item = {|_multi: ItemArrayNode | ItemObjectNode|};
-export type Item = {
- _multi: ItemTree;
-};
diff --git a/packages/perseus/src/multi-items/items.ts b/packages/perseus/src/multi-items/items.ts
deleted file mode 100644
index 5666101a73..0000000000
--- a/packages/perseus/src/multi-items/items.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * Utility functions for constructing and manipulating multi-items.
- *
- * These functions apply *specifically* to Items and ItemTrees - things that
- * actually semantically *are* multi-items. For more general functions for
- * traversing and manipulating *anything* shaped like a multi-item (like a
- * renderer tree or a score tree or, well, a multi-item), see trees.js.
- */
-
-import {Errors, PerseusError} from "@khanacademy/perseus-core";
-
-import shapes from "./shapes";
-import {buildMapper} from "./trees";
-
-import type {
- Item,
- ItemTree,
- ContentNode,
- HintNode,
- TagsNode,
- ItemArrayNode,
- ItemObjectNode,
-} from "./item-types";
-import type {Shape} from "./shape-types";
-
-/**
- * Return a semantically empty ItemTree that conforms to the given shape.
- *
- * - An empty content node has an empty content string and no widgets/images.
- * - An empty hint node has an empty content string and no widgets/images.
- * - An empty array node has no elements.
- * - An empty object node has a semantically empty node for each of its keys.
- * (That is, we recursively call buildEmptyItemTreeForShape for each key.)
- */
-export function buildEmptyItemTreeForShape(shape: Shape): ItemTree {
- if (shape.type === "content") {
- return {
- __type: "content",
- content: "",
- images: {},
- widgets: {},
- };
- }
- if (shape.type === "hint") {
- return {
- __type: "hint",
- replace: false,
- content: "",
- images: {},
- widgets: {},
- };
- }
- if (shape.type === "tags") {
- return [] as TagsNode;
- }
- if (shape.type === "array") {
- return [] as ItemArrayNode;
- }
- if (shape.type === "object") {
- const valueShapes = shape.shape;
- const object: ItemObjectNode = {};
- Object.keys(valueShapes).forEach((key) => {
- object[key] = buildEmptyItemTreeForShape(valueShapes[key]);
- });
- return object;
- }
- throw new PerseusError(
- // @ts-expect-error - TS2339 - Property 'type' does not exist on type 'never'.
- `unexpected shape type ${shape.type}`,
- Errors.InvalidInput,
- );
-}
-
-/**
- * Return a semantically empty Item that conforms to the given shape.
- *
- * - An empty content node has an empty content string and no widgets/images.
- * - An empty hint node has an empty content string and no widgets/images.
- * - An empty array node has no elements.
- * - An empty object node has a semantically empty node for each of its keys.
- * (That is, we recursively call buildEmptyItemTreeForShape for each key.)
- */
-export function buildEmptyItemForShape(shape: Shape): Item {
- return treeToItem(buildEmptyItemTreeForShape(shape));
-}
-
-/**
- * Given an Item and its Shape, yield all of its content nodes to the callback.
- */
-export function findContentNodesInItem(
- item: Item,
- shape: Shape,
- callback: (c: ContentNode) => any,
-) {
- const itemTree = itemToTree(item);
- buildMapper().setContentMapper(callback).mapTree(itemTree, shape);
-}
-
-/**
- * Given an Item and its Shape, yield all of its hint nodes to the callback.
- */
-export function findHintNodesInItem(
- item: Item,
- shape: Shape,
- callback: (h: HintNode) => any,
-) {
- const itemTree = itemToTree(item);
- buildMapper().setHintMapper(callback).mapTree(itemTree, shape);
-}
-
-/**
- * Given an ItemTree, return a Shape that it conforms to.
- *
- * The Shape might not be complete or correct Shape that this Item was designed
- * for. If you have access to the intended Shape, use that instead.
- */
-export function inferItemShape(item: Item): Shape {
- const itemTree = itemToTree(item);
- return inferItemTreeShape(itemTree);
-}
-
-function inferItemTreeShape(node: ItemTree): Shape {
- if (Array.isArray(node)) {
- if (node.length) {
- if (typeof node[0] === "string") {
- // There's no ItemTree that can manifest as a string.
- // So, an array of strings must be a TagsNode, not ArrayNode.
- return shapes.tags;
- }
- // Otherwise, assume that this is a valid ArrayNode, and
- // therefore the shape of the first element applies to all
- // elements in the array.
- return shapes.arrayOf(inferItemTreeShape(node[0]));
- }
- // The array is empty, so we arbitrarily guess that it's a content
- // array. As discussed in the docstring, this might be incorrect,
- // and you shouldn't depend on it.
- return shapes.arrayOf(shapes.content);
- }
- if (
- // TODO(mdr): Remove #LegacyContentNode support.
- typeof node === "object" &&
- (node.__type === "content" || node.__type === "item")
- ) {
- return shapes.content;
- }
- if (typeof node === "object" && node.__type === "hint") {
- return shapes.hint;
- }
- if (typeof node === "object") {
- const valueShapes: Record = {};
- Object.keys(node).forEach((key) => {
- valueShapes[key] = inferItemTreeShape(node[key]);
- });
- return shapes.shape(valueShapes);
- }
- throw new PerseusError(
- `unexpected multi-item node ${JSON.stringify(node)}`,
- Errors.InvalidInput,
- );
-}
-
-/**
- * Convert the given Item to an ItemTree, by unwrapping the `_multi` key.
- */
-export function itemToTree(item: Item): ItemTree {
- return item._multi;
-}
-
-/**
- * Convert the given ItemTree to an Item, by wrapping it in the `_multi` key.
- */
-export function treeToItem(node: ItemTree): Item {
- return {_multi: node};
-}
diff --git a/packages/perseus/src/multi-items/multi-renderer.tsx b/packages/perseus/src/multi-items/multi-renderer.tsx
deleted file mode 100644
index 9b01836fb0..0000000000
--- a/packages/perseus/src/multi-items/multi-renderer.tsx
+++ /dev/null
@@ -1,584 +0,0 @@
-/* eslint-disable @khanacademy/ts-no-error-suppressions */
-/* eslint-disable react/no-unsafe */
-// TODO(mdr): There are some TypeScript errors in this file, and they're tricky, so
-// I'm skipping TypeScript errors for now.
-/**
- * Main entry point to the MultiRenderer render portion.
- *
- * This file exposes the `MultiRenderer` component which performs
- * multi-rendering. To multi-render a question, pass in the content of the item
- * to the `MultiRenderer` component as a props. Then, pass in a function which
- * takes an object of renderers (in the same structure as the content), and
- * return a render tree. The `MultiRenderer` component will allow you to
- * combine scores, serialized state, etc. without having to manually call on
- * each of the functions. It also handles inter-widgets requests between the
- * different renderers.
- *
- * Example:
- *
- * item = {_multi: {
- * left: ,
- * right: [, ],
- * }}
- * shape = shapes.shape({
- * left: shapes.content,
- * right: shapes.arrayOf(shapes.content),
- * })
- *
- *
- * {({renderers}) =>
- *
- *
{renderers.left}
- *
- * {renderers.right.map(r => {r} )}
- *
- *
- * }
- *
- */
-import {Errors} from "@khanacademy/perseus-core";
-import {StyleSheet, css} from "aphrodite";
-// eslint-disable-next-line import/no-extraneous-dependencies
-import lens from "hubble";
-import * as React from "react";
-
-import {PerseusI18nContext} from "../components/i18n-context";
-import {DependenciesContext} from "../dependencies";
-import HintsRenderer from "../hints-renderer";
-import {Log} from "../logging/log";
-import Renderer from "../renderer";
-import {combineScores, keScoreFromPerseusScore} from "../util/scoring";
-
-import {itemToTree} from "./items";
-import {buildMapper} from "./trees";
-
-import type {Item, ContentNode, HintNode, TagsNode} from "./item-types";
-import type {Shape, ArrayShape} from "./shape-types";
-import type {Tree} from "./tree-types";
-import type {TreeMapper, ContentMapper, HintMapper, Path} from "./trees";
-import type {
- APIOptions,
- FilterCriterion,
- PerseusDependenciesV2,
- PerseusScore,
- Widget,
-} from "../types";
-import type {PropsFor} from "@khanacademy/wonder-blocks-core";
-
-type Hint = any; // TODO(mdr)
-type Score = any; // TODO(mdr)
-type SerializedState = any; // TODO(mdr)
-
-type RendererProps = PropsFor;
-
-type ContentRendererElement = React.ReactElement;
-type HintRendererElement = React.ReactElement;
-type ContentRendererData = {
- makeRenderer: () => ContentRendererElement;
- ref: Renderer | null | undefined;
-};
-type FindWidgetsFunc = (
- criterion: FilterCriterion,
-) => ReadonlyArray;
-type HintRendererData = {
- makeRenderer: () => HintRendererElement;
- findExternalWidgets: FindWidgetsFunc | null | undefined;
- ref: null;
- hint: Hint;
-};
-type RendererData = ContentRendererData | HintRendererData;
-// @ts-expect-error - TS2315 - Type 'Tree' is not generic.
-type RendererDataTree = Tree;
-
-/**
- * TODO(somewhatabstract, JIRA-XXXX):
- * Some usage of this type somewhere is causing TypeScript to believe that these
- * elements should be components. Don't know where that is, to fix it up
- * properly. In reality, these types are just too hard to use and aren't
- * really helping us out as I now have to suppress this rather than
- * get value from the type. :(
- */
-// @ts-expect-error - TS2315 - Type 'Tree' is not generic.
-type RendererTree = Tree;
-// @ts-expect-error - TS2315 - Type 'Tree' is not generic.
-type ScoreTree = Tree;
-// @ts-expect-error - TS2315 - Type 'Tree' is not generic.
-type SerializedStateTree = Tree;
-
-type Props = {
- item: Item;
- shape: Shape;
- children: (tree: {renderers: RendererTree}) => React.ReactElement;
- serializedState?: SerializedStateTree | null | undefined;
- onSerializedStateUpdated?: (state: SerializedStateTree) => void;
- onInteractWithWidget?: (id: string) => void;
- apiOptions?: APIOptions;
- reviewMode?: boolean | null | undefined;
-
- dependencies: PerseusDependenciesV2;
-};
-type State = {
- // We cache functions to generate renderers and refs in `rendererDataTree`,
- // and change them every time content changes. This isn't just a performance
- // optimization; see `_makeContentRendererData` for more discussion.
- rendererDataTree: RendererDataTree | null | undefined;
- // But, if traversing the tree fails, we store the Error in `renderError`.
- renderError: Error | null | undefined;
-};
-
-class MultiRenderer extends React.Component {
- static contextType = PerseusI18nContext;
- declare context: React.ContextType;
-
- rendererDataTreeMapper: TreeMapper<
- ContentNode,
- ContentRendererData,
- HintNode,
- HintRendererData,
- TagsNode,
- null
- >;
- getRenderersMapper: TreeMapper<
- ContentRendererData,
- ContentRendererElement,
- HintRendererData,
- HintRendererElement,
- null,
- null
- >;
-
- constructor(props: Props) {
- super(props);
-
- this.rendererDataTreeMapper = buildMapper()
- // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'ContentNode'.
- .setContentMapper((c, _, p) => this._makeContentRendererData(c, p))
- // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'HintNode'.
- .setHintMapper((h) => this._makeHintRendererData(h))
- .setTagsMapper((t) => null);
-
- // @ts-expect-error - TS2322 - Type 'TreeMapperForLeavesAndCollections' is not assignable to type 'TreeMapper'.
- this.getRenderersMapper = buildMapper()
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setContentMapper((c) => c.makeRenderer())
- // @ts-expect-error - TS2571 - Object is of type 'unknown'.
- .setHintMapper((h) => h.makeRenderer())
- .setArrayMapper((renderers, data, shape) =>
- this._annotateRendererArray(
- renderers as any,
- data as any,
- shape,
- ),
- );
-
- // Keep state in sync with props.
- this.state = this._tryMakeRendererState(this.props);
- }
-
- UNSAFE_componentWillReceiveProps(nextProps: Props) {
- // Keep state in sync with props.
- if (nextProps.item !== this.props.item) {
- this.setState(this._tryMakeRendererState(nextProps));
- }
- }
-
- /**
- * Attempt to build a State that includes a renderer tree corresponding to
- * the item provided in props. On error, return a state with `renderError`
- * set instead.
- */
- _tryMakeRendererState(props: Props): State {
- try {
- return {
- rendererDataTree: this._makeRendererDataTree(
- props.item,
- props.shape,
- ),
- renderError: null,
- };
- } catch (e: any) {
- Log.error("Error building tree state", Errors.Internal, {
- cause: e,
- });
- return {
- rendererDataTree: null,
- renderError: e,
- };
- }
- }
-
- _handleSerializedStateUpdated: (path: Path, newState?: any) => void = (
- path,
- newState,
- ) => {
- const {onSerializedStateUpdated} = this.props;
-
- if (onSerializedStateUpdated) {
- const oldState = this._getSerializedState(
- this.props.serializedState,
- );
- onSerializedStateUpdated(
- lens(oldState).set(path, newState).freeze(),
- );
- }
- };
-
- /**
- * Props that aren't directly used by the MultiRenderer are delegated to
- * the underlying Renderers.
- */
- _getRendererProps(): RendererProps {
- // `item`, `children`, and others are unused. I'm
- // explicitly pulling them out of `this.props` so I don't pass them to
- // ``. I'm not sure how else to do this.
- const {
- item: _,
- children: __,
- shape: ___,
- serializedState: ____,
- onSerializedStateUpdated: _____,
- ...otherProps
- } = this.props;
- return {
- ...otherProps,
- strings: this.context.strings,
- };
- }
-
- /**
- * Construct a Renderer and a ref placeholder for the given ContentNode.
- */
- _makeContentRendererData(
- content: ContentNode,
- path: Path,
- ): ContentRendererData {
- // NOTE(emily): The `findExternalWidgets` function here is computed
- // inline and thus changes each time we run this function. If it
- // were to change every render, it would cause the Renderer to
- // re-render a lot more than is necessary. Don't re-compute this
- // element unless it is necessary!
- // HACK(mdr): TypeScript can't prove that this is a ContentRendererData,
- // because of how we awkwardly construct it in order to obtain a
- // circular reference. But it is, I promise.
- const data: any = {ref: null, makeRenderer: null};
-
- const refFunc = (e: any) => (data.ref = e);
- const findExternalWidgets = (criterion: any) =>
- this._findWidgets(data, criterion);
- const handleSerializedState = (state: any) =>
- this._handleSerializedStateUpdated(path, state);
-
- data.makeRenderer = () => (
- /**
- * TODO(somewhatabstract, JIRA-XXXX):
- * `content` contains props that Renderer doesn't have. However,
- * since the type for `content` is not exact, it's hard to know
- * if this spread is including undocumented props so mapping
- * one to one could introduce a bug. Need to work out the exact
- * type for ContentNode and then fix this.
- */ // @ts-expect-error - TS2322 - Type '{ ref: (e: any) => any; findExternalWidgets: (criterion: any) => readonly (Widget | null | undefined)[]; serializedState: any; onSerializedStateUpdated: (state: any) => void; __type: "content" | "item"; ... 9 more ...; reviewMode?: boolean | ... 1 more ... | undefined; }' is not assignable to type 'InexactPartial & Readonly<{ children?: ReactNode; }>, "content" | "images" | "onRender" | "linterContext" | "widgets" | "alwaysUpdate" | ... 6 more ... | "serializedState">>'.
-
- );
- return data;
- }
-
- /**
- * Construct a Renderer for the given HintNode, and keep track of the hint
- * itself for future use, too.
- */
- _makeHintRendererData(hint: HintNode): HintRendererData {
- // TODO(mdr): Once HintsRenderer supports inter-widget communication,
- // give it a ref. Until then, leave the ref null forever, to avoid
- // confusing the findWidgets functions.
- //
- // NOTE(davidflanagan): As a partial step toward inter-widget
- // communication we're going to pass a findExternalWidgets function
- // (using a dummy data object). This allows passage-ref widgets in
- // hints to use findWidget() to find the passage widgets they reference.
- // Note that this is one-way only, however. It does not allow
- // widgets in the question to find widgets in the hints, for example.
- const findExternalWidgets = (criterion) =>
- this._findWidgets({} as any, criterion);
-
- return {
- hint,
- findExternalWidgets, // _annotateRendererArray() needs this
- ref: null,
- makeRenderer: () => (
-
- ),
- };
- }
-
- /**
- * Construct a tree of interconnected RendererDatas, corresponding to the
- * given item. Called in `_tryMakeRendererState`, in order to store this
- * tree in the component state.
- */
- _makeRendererDataTree(item: Item, shape: Shape): RendererDataTree {
- const itemTree = itemToTree(item);
- return this.rendererDataTreeMapper.mapTree(itemTree, shape);
- }
-
- /**
- * Return all widgets that meet the given criterion, from all Renderers
- * except the Renderer that triggered this call.
- *
- * This function is provided to each Renderer's `findExternalWidgets` prop,
- * which enables widgets in different Renderers to discover each other and
- * communicate.
- */
- _findWidgets(
- callingData: RendererData,
- filterCriterion: FilterCriterion,
- ): ReadonlyArray {
- const results: Array = [];
-
- this._mapRenderers((data) => {
- if (callingData !== data && data.ref) {
- results.push(...data.ref.findInternalWidgets(filterCriterion));
- }
- });
-
- return results;
- }
-
- /**
- * Copy the renderer tree, apply the given transformation to the leaf nodes
- * and the optional given transformation to the array nodes, and return the
- * result.
- *
- * Used to provide structured data to the call site (the Renderer tree on
- * `render`, the Score tree on `getScores`, etc.), and to traverse the
- * renderer tree even when we disregard the output (like in
- * `_findWidgets`).
- */
- _mapRenderers(
- // eslint-disable-next-line no-restricted-syntax
- leafMapper: ContentMapper &
- HintMapper,
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic.
- ): Tree | null | undefined {
- const {rendererDataTree} = this.state;
-
- if (!rendererDataTree) {
- return null;
- }
-
- const mapper = buildMapper()
- .setContentMapper(leafMapper)
- .setHintMapper(leafMapper);
- return mapper.mapTree(rendererDataTree, this.props.shape);
- }
-
- _scoreFromRef(ref?: Renderer | null): Score {
- if (!ref) {
- return null;
- }
-
- const [guess, score] = ref.guessAndScore();
- let state;
- if (ref.getSerializedState) {
- state = ref.getSerializedState();
- }
- return keScoreFromPerseusScore(score, guess, state);
- }
-
- /**
- * Return a tree in the shape of the multi-item, with scores at each of
- * the content nodes and `null` at the other leaf nodes.
- */
- getScores(): ScoreTree {
- return this._mapRenderers((data) => this._scoreFromRef(data.ref));
- }
-
- /**
- * Return a single composite score for all rendered content nodes.
- * The `guess` is a tree in the shape of the multi-item, with an individual
- * guess at each content node and `null` at the other leaf nodes.
- */
- score(): Score {
- const scores: Array = [];
- const state: Array<
- | any
- | {
- [id: string]: any;
- }
- > = [];
- const guess = this._mapRenderers((data) => {
- if (!data.ref) {
- return null;
- }
-
- if (data.ref.getSerializedState) {
- state.push(data.ref.getSerializedState());
- }
-
- scores.push(data.ref.score());
- return data.ref?.getUserInput();
- });
-
- const combinedScore = scores.reduce(combineScores);
-
- return keScoreFromPerseusScore(combinedScore, guess, state);
- }
-
- /**
- * Return a tree in the shape of the multi-item, with serialized state at
- * each of the content nodes and `null` at the other leaf nodes.
- *
- * If the lastSerializedState argument is supplied, this function will fill
- * in the state of not-currently-rendered content and hint nodes with the
- * values from the previous serialized state. If no lastSerializedState is
- * supplied, `null` will be returned for not-currently-rendered content and
- * hint nodes.
- */
- _getSerializedState(
- lastSerializedState?: SerializedStateTree,
- ): SerializedStateTree {
- return this._mapRenderers((data, _, path) => {
- if (data.ref) {
- return data.ref.getSerializedState();
- }
- if (lastSerializedState) {
- return lens(lastSerializedState).get(path);
- }
- return null;
- });
- }
-
- /**
- * Given a tree in the shape of the multi-item, with serialized state at
- * each of the content nodes, restore each state to the corresponding
- * renderer if currently mounted.
- */
- restoreSerializedState(
- serializedState: SerializedState,
- callback?: () => any,
- ) {
- // We want to call our async callback only once all of the childrens'
- // callbacks have run. We add one to this counter before we call out to
- // each renderer and decrement it when it runs our callback.
- let numCallbacks = 0;
- const countCallback = () => {
- numCallbacks--;
- if (callback && numCallbacks === 0) {
- callback();
- }
- };
-
- this._mapRenderers((data, _, path) => {
- if (!data.ref) {
- return;
- }
-
- const state = lens(serializedState).get(path);
- if (!state) {
- return;
- }
-
- numCallbacks++;
- data.ref?.restoreSerializedState(state, countCallback);
- });
- }
-
- /**
- * Given an array of renderers, if it happens to be an array of *hint*
- * renderers, then attach a `firstN` method to the array, which allows the
- * layout to render the hints together in one HintsRenderer.
- */
- _annotateRendererArray(
- renderers: ReadonlyArray,
- rendererDatas: ReadonlyArray,
- shape: ArrayShape,
- ): ReadonlyArray {
- if (shape.elementShape.type === "hint") {
- // The shape says that these are HintRendererDatas, even though
- // it's not provable at compile time, so perform a cast.
- const hintRendererDatas: ReadonlyArray =
- rendererDatas as any;
-
- renderers = [...renderers];
- (renderers as any).firstN = (n: any) => (
- d.hint)}
- hintsVisible={n}
- />
- );
- }
- return renderers;
- }
-
- /**
- * Return a tree in the shape of the multi-item, with a Renderer at each
- * content node and a HintRenderer at each hint node.
- *
- * This is generated by running each of the `makeRenderer` functions at the
- * leaf nodes.
- */
- _getRenderers(): RendererTree {
- return this.getRenderersMapper.mapTree(
- this.state.rendererDataTree,
- this.props.shape,
- );
- }
-
- render(): React.ReactNode {
- if (this.state.renderError) {
- return (
-
- {this.context.strings.errorRendering({
- error: String(this.state.renderError),
- })}
-
- );
- }
-
- // Pass the renderer tree to the `children` function, which will
- // determine the actual content of this component.
- return (
-
- {this.props.children({
- renderers: this._getRenderers(),
- })}
-
- );
- }
-}
-
-const styles = StyleSheet.create({
- error: {
- color: "red",
- },
-});
-
-export default MultiRenderer;
diff --git a/packages/perseus/src/multi-items/prop-type-builders.ts b/packages/perseus/src/multi-items/prop-type-builders.ts
deleted file mode 100644
index 397eba865c..0000000000
--- a/packages/perseus/src/multi-items/prop-type-builders.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * Utility functions to build React PropTypes for multi-items and shapes.
- *
- * If you're writing new components, though, consider using the Item and Shape
- * types instead.
- */
-/* instanbul ignore file */
-
-import {Errors, PerseusError} from "@khanacademy/perseus-core";
-import PropTypes from "prop-types";
-
-import type {Shape} from "./shape-types";
-
-/**
- * Return a PropType that accepts Items of the given shape, and rejects other
- * objects.
- *
- * Usage: `propTypes: {item: buildPropTypeForShape(myShape)}`
- */
-export function buildPropTypeForShape(shape: Shape): any {
- return PropTypes.oneOfType([
- PropTypes.shape({
- _multi: buildTreePropTypeForShape(shape),
- }),
- PropTypes.oneOf([null, undefined]),
- ]);
-}
-
-/**
- * Return a PropType that accepts ItemTrees of the given shape, and rejects
- * other objects.
- */
-function buildTreePropTypeForShape(shape: Shape): any {
- if (shape.type === "content") {
- return PropTypes.shape({
- // TODO(mdr): Remove #LegacyContentNode support.
- __type: PropTypes.oneOf(["content", "item"]).isRequired,
- content: PropTypes.string,
- images: PropTypes.objectOf(PropTypes.any),
- widgets: PropTypes.objectOf(PropTypes.any),
- });
- }
- if (shape.type === "hint") {
- return PropTypes.shape({
- __type: PropTypes.oneOf(["hint"]).isRequired,
- content: PropTypes.string,
- images: PropTypes.objectOf(PropTypes.any),
- widgets: PropTypes.objectOf(PropTypes.any),
- replace: PropTypes.bool,
- });
- }
- if (shape.type === "tags") {
- return PropTypes.arrayOf(PropTypes.string.isRequired);
- }
- if (shape.type === "array") {
- const elementPropType = buildTreePropTypeForShape(shape.elementShape);
- return PropTypes.arrayOf(elementPropType.isRequired);
- }
- if (shape.type === "object") {
- const valueShapes = shape.shape;
- const propTypeShape: Record = {};
- Object.keys(valueShapes).forEach((key) => {
- propTypeShape[key] = buildTreePropTypeForShape(
- valueShapes[key],
- ).isRequired;
- });
- return PropTypes.shape(propTypeShape);
- }
- throw new PerseusError(
- // @ts-expect-error - TS2339 - Property 'type' does not exist on type 'never'.
- `unexpected shape type ${shape.type}`,
- Errors.InvalidInput,
- );
-}
diff --git a/packages/perseus/src/multi-items/shape-types.ts b/packages/perseus/src/multi-items/shape-types.ts
deleted file mode 100644
index 4ffa67f5f3..0000000000
--- a/packages/perseus/src/multi-items/shape-types.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Type definitions for multi-item shapes.
- *
- * A shape is an object that serves as a runtime type declaration: it specifies
- * a tree structure for a particular class of multi-item.
- *
- * We use shapes instead static compile-time typing because the CMS needs to
- * understand the shape of our content library's multi-items at runtime, and
- * it's not always possible to infer the full shape from an example multi-item.
- *
- * Shapes also enable us to traverse a multi-item-shaped tree with confidence,
- * even when we can't infer the shape from the tree alone.
- *
- * We *could* go all-in on a more general library to make certain types
- * runtime-inspectable, in order to DRY some things up, but that's probably a
- * big ol' infrastructural magic mess, and the narrower scope of Shapes makes
- * it easier to be confident that we've covered all cases rather than having to
- * deal with all possible Javascript types.
- */
-export type ContentShape = {
- type: "content";
-};
-export type HintShape = {
- type: "hint";
-};
-export type TagsShape = {
- type: "tags";
-};
-export type ArrayShape = {
- type: "array";
- /**
- * Each element of an ArrayNode has the same shape, which is specified by
- * the `elementShape` property.
- */
- elementShape: Shape;
-};
-export type ObjectShape = {
- type: "object";
- /**
- * Each property of an ObjectNode has its own shape, which is specified
- * under the corresponding key in the `shape` property.
- */
- shape: {
- [k: string]: Shape;
- };
-};
-export type Shape =
- | ContentShape
- | HintShape
- | TagsShape
- | ArrayShape
- | ObjectShape;
diff --git a/packages/perseus/src/multi-items/shapes.ts b/packages/perseus/src/multi-items/shapes.ts
deleted file mode 100644
index 6f70a87b36..0000000000
--- a/packages/perseus/src/multi-items/shapes.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Utility functions for constructing and inferring multi-item shapes.
- *
- * A shape is an object that serves as a runtime type declaration: it specifies
- * a tree structure for a particular class of multi-item. See shape-types.js
- * for further discussion.
- *
- * This module allows you to construct arbitrary Shape trees, by combining
- * leaf node shapes like `content` and `hint` into composite shapes like
- * `arrayOf(shape({foo: content, bar: hint}))`.
- */
-import type {
- Shape,
- ContentShape,
- HintShape,
- TagsShape,
- ArrayShape,
- ObjectShape,
-} from "./shape-types";
-
-/**
- * These tools allow you to construct arbirtary shapes, by combining simple
- * leaf shapes like `content` and `hint` into composite shapes like
- * `arrayOf(shape({question: content, hints: arrayOf(hint)}))`.
- */
-const contentShape: ContentShape = {
- type: "content",
-};
-const hintShape: HintShape = {
- type: "hint",
-};
-const tagsShape: TagsShape = {
- type: "tags",
-};
-const buildArrayShape = (elementShape: Shape): ArrayShape => ({
- type: "array",
- elementShape,
-});
-const buildObjectShape = (shape: {[k: string]: Shape}): ObjectShape => ({
- type: "object",
- shape,
-});
-const hintsShape: ArrayShape = buildArrayShape(hintShape);
-
-export default {
- content: contentShape,
- hint: hintShape,
- hints: hintsShape,
- tags: tagsShape,
- arrayOf: buildArrayShape,
- shape: buildObjectShape,
-};
diff --git a/packages/perseus/src/multi-items/tree-types.ts b/packages/perseus/src/multi-items/tree-types.ts
deleted file mode 100644
index f4df0c6fb9..0000000000
--- a/packages/perseus/src/multi-items/tree-types.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Type definitions for generic trees that are shaped like multi-items.
- *
- * Multi-items are trees! But we also often have other trees that are shaped
- * like multi-items - for example, if we map a multi-item tree into a tree of
- * renderer info and state, and then map that again into a tree of just the
- * renderer nodes, like we do in MultiRenderer.
- *
- * Therefore, we provide the type Tree, which represents a tree that's
- * shaped like a multi-item, but the data that lives at each type of leaf could
- * be anything.
- *
- * So, in a Tree, content leaf nodes have type C, and hint leaf nodes
- * have type H. An ItemTree is a Tree.
- *
- * That is, we still preserve the distinction between node types, and thereby
- * enforce the Tree's adherence to some multi-item Shape, but we're flexible
- * about exactly what type of data the tree contains at its leaves.
- *
- * This enables us to write generic tree-traversal and tree-mapping functions,
- * as you'll see in trees.js.
- */
-
-// TODO(jeremy): We lost "arrayness" in D69450. It'd be nice to restore that
-// because right now ArrayNode doesn't add anything.
-// @ts-expect-error - TS2456 - Type alias 'ArrayNode' circularly references itself. | TS2315 - Type 'Tree' is not generic.
-export type ArrayNode = Tree;
-export type ObjectNode = {
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic.
- [k: string]: Tree;
-};
-
-/**
- * TODO(somewhatabstract, JIRA-XXXX):
- * We should ditch this type or create some helpers for working with it.
- * As it stands, consumers have to check each node's type before using it
- * in order to ensure that TypeScript can track its refinement and appropriate use.
- * It's messy.
- */
-// @ts-expect-error - TS2456 - Type alias 'Tree' circularly references itself. | TS2315 - Type 'ArrayNode' is not generic.
-export type Tree =
- | C
- | H
- | T
- // @ts-expect-error - TS2315 - Type 'ArrayNode' is not generic.
- | ArrayNode
- | ObjectNode;
diff --git a/packages/perseus/src/multi-items/trees.ts b/packages/perseus/src/multi-items/trees.ts
deleted file mode 100644
index 6ef124139c..0000000000
--- a/packages/perseus/src/multi-items/trees.ts
+++ /dev/null
@@ -1,359 +0,0 @@
-/**
- * Utility functions for manipulating multi-item-shaped trees.
- *
- * Multi-items are trees! But we also often have other trees that are shaped
- * like multi-items - for example, if we map a multi-item tree into a tree of
- * renderer info and state, and then map that again into a tree of just the
- * renderer nodes, like we do in MultiRenderer. See tree-types.js for further
- * discussion.
- *
- * These functions enable us to manipulate generic multi-item-shaped trees,
- * regardless of what type of data they contain at their leaves. You can use
- * the mapper functions to transform a tree into another tree of the same
- * shape, or to discover all the nodes of a particular type.
- *
- * We expose two simple mapper functions (mapContentNodes and mapHintNodes),
- * and also a more complex interface for creating a mapping over all of a
- * tree's node types simultaneously:
- *
- * `buildMapper()` returns a TreeMapper object that allows you to build your
- * mapper object one node type at a time. Then, you can execute your mapping by
- * calling the `mapTree` method.
- *
- * For example:
- * const renderers = buildMapper()
- * .setContentMapper(this.renderContentNode)
- * .setHintMapper(this.renderHintNode)
- * .setTagsMapper(this.renderTagsNode)
- * .setArrayMapper(this.hideSkippedQuestions)
- * .mapTree(tree, shape);
- *
- * This will copy the given tree, apply the given transformations to the
- * content, hint, and array nodes respectively, and return the resulting tree.
- *
- * For node types whose mappers aren't specified, we default to the identity
- * function. (This builder interface enables us to implement that default
- * behavior in a provably type-safe way, while not requiring the call site to
- * be aware of all the node types. Hooray!)
- *
- * The call to `setArrayMapper` must come last, because the array mapper's
- * argument types depend on the other mappers' types. See ArrayMapper for more
- * details.
- *
- * WARNING: These functions trust that the provided tree conforms to the
- * provided shape. If not, behavior is undefined and may not respect the type
- * signatures specified here.
- */
-
-import {Errors, PerseusError} from "@khanacademy/perseus-core";
-
-import type {
- Shape,
- ContentShape,
- HintShape,
- TagsShape,
- ArrayShape,
-} from "./shape-types";
-import type {Tree, ArrayNode, ObjectNode} from "./tree-types";
-
-/**
- * The sequence of edges that lead to a particular node in a Tree.
- * Elements can be `string` to correspond to an ObjectNode key, or `number` to
- * correspond to an ArrayNode index.
- */
-export type Path = ReadonlyArray;
-
-/**
- * These are function interfaces for mapping over various types of tree nodes.
- *
- * ArrayMapper is a bit more complicated than the leaf node mappers. It's
- * executed in the context of a `mapTree` call, after we've finished mapping
- * its child nodes, so the function has access to both the resulting array
- * (with mapped elements) and the original array (with the original untouched
- * elements).
- *
- * The ArrayMapper then has the opportunity to apply a final transformation to
- * the resulting array, like filtering certain elements or (in the hacky
- * MultiRenderer case) attaching a `renderHints` method to arrays of hint
- * renderers :)
- *
- * This is why `TreeMapper#setArrayMapper` must be called last: ArrayMapper's
- * types depend on the ContentMapper and HintMapper's types. And, since you can
- * only specify one mapper at a time in this builder interface (which is
- * necessary to provide default mappers in a type-safe way), you need your
- * dependencies to already be in place by the time you call `setArrayMapper`.
- * Otherwise, we'd have to set the ArrayMapper and *hope* that you *eventually*
- * provide a compatible ContentMapper and HintMapper, which is difficult to
- * prove at compile time.
- *
- * There's no ObjectMapper here, but not for any particular reason. We just
- * don't have a use case for it yet, so we haven't built it yet.
- */
-export type ContentMapper = (
- content: CI,
- shape: ContentShape,
- path: Path,
-) => CO;
-export type HintMapper = (hint: HI, shape: HintShape, path: Path) => HO;
-export type TagsMapper = (tag: TI, shape: TagsShape, path: Path) => TO;
-export type ArrayMapper = (
- // @ts-expect-error - TS2315 - Type 'ArrayNode' is not generic.
- mappedArray: ArrayNode,
- // @ts-expect-error - TS2315 - Type 'ArrayNode' is not generic.
- originalArray: ArrayNode,
- shape: ArrayShape,
- path: Path,
- // @ts-expect-error - TS2315 - Type 'ArrayNode' is not generic.
-) => ArrayNode;
-
-/**
- * A TreeMapper is a collection of node mappers, which, together, compose the
- * behavior for mapping over an entire tree.
- *
- * This serves as the interface for the two TreeMapper classes, including both
- * the internal mapper properties that we care about, and the `mapTree`
- * function that the call site will use.
- */
-export interface TreeMapper {
- content: ContentMapper;
- hint: HintMapper;
- tags: TagsMapper;
- array: ArrayMapper;
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic. | TS2315 - Type 'Tree' is not generic.
- mapTree(tree: Tree, shape: Shape): Tree;
-}
-
-/**
- * This is a TreeMapper that only has mappers specified for its leaf nodes; its
- * array mapper is the identity function.
- *
- * This is the TreeMapper initially returned by `buildMapper`. It allows you to
- * change the types of your ContentMapper and HintMapper, which is safe because
- * none of the other mappers that depend on those types (aka ArrayMapper) have
- * been specified yet. (Or, more specifically, the ArrayMapper is currently
- * `identity`, which can trivially vary with the ContentMapper and HintMapper's
- * types.)
- *
- * Once you call `setArrayMapper`, however, we move to the other class:
- * TreeMapperForLeavesAndCollections.
- */
-class TreeMapperJustForLeaves {
- content: ContentMapper;
- hint: HintMapper;
- tags: TagsMapper;
- array: ArrayMapper;
-
- constructor(
- content: ContentMapper,
- hint: HintMapper,
- tags: TagsMapper,
- ) {
- this.content = content;
- this.hint = hint;
- this.tags = tags;
- this.array = identity;
- }
-
- setContentMapper(
- newContentMapper: ContentMapper,
- ): TreeMapperJustForLeaves {
- return new TreeMapperJustForLeaves(
- newContentMapper,
- this.hint,
- this.tags,
- );
- }
-
- setHintMapper(
- newHintMapper: HintMapper,
- ): TreeMapperJustForLeaves {
- return new TreeMapperJustForLeaves(
- this.content,
- newHintMapper,
- this.tags,
- );
- }
-
- setTagsMapper(
- newTagsMapper: TagsMapper,
- ): TreeMapperJustForLeaves {
- return new TreeMapperJustForLeaves(
- this.content,
- this.hint,
- newTagsMapper,
- );
- }
-
- setArrayMapper(
- newArrayMapper: ArrayMapper,
- ): TreeMapperForLeavesAndCollections {
- return new TreeMapperForLeavesAndCollections(
- this.content,
- this.hint,
- this.tags,
- newArrayMapper,
- );
- }
-
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic. | TS2315 - Type 'Tree' is not generic.
- mapTree(tree: Tree, shape: Shape): Tree {
- return mapTree(tree, shape, [], this);
- }
-}
-
-/**
- * This is a TreeMapper that already has an ArrayMapper specified, so its
- * ContentMapper and HintMapper are now locked in.
- */
-class TreeMapperForLeavesAndCollections {
- content: ContentMapper;
- hint: HintMapper;
- tags: TagsMapper;
- array: ArrayMapper;
-
- constructor(
- content: ContentMapper,
- hint: HintMapper,
- tags: TagsMapper,
- array: ArrayMapper,
- ) {
- this.content = content;
- this.hint = hint;
- this.tags = tags;
- this.array = array;
- }
-
- setArrayMapper(
- newArrayMapper: ArrayMapper,
- ): TreeMapperForLeavesAndCollections {
- return new TreeMapperForLeavesAndCollections(
- this.content,
- this.hint,
- this.tags,
- newArrayMapper,
- );
- }
-
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic. | TS2315 - Type 'Tree' is not generic.
- mapTree(tree: Tree, shape: Shape): Tree {
- return mapTree(tree, shape, [], this);
- }
-}
-
-function identity(x: T): T {
- return x;
-}
-
-/**
- * Return a new TreeMapper that will perform a no-op transformation on an input
- * tree. To make it useful, chain any combination of `setContentMapper`,
- * `setHintMapper`, `setTagMapper`, and `setArrayMapper` to specify
- * transformations for the individual node types.
- */
-// @ts-expect-error - TS2300 - Duplicate identifier 'C'. | TS2300 - Duplicate identifier 'H'. | TS2300 - Duplicate identifier 'T'.
-export function buildMapper(): TreeMapperJustForLeaves<
- C,
- C,
- H,
- H,
- T,
- T
-> {
- return new TreeMapperJustForLeaves(identity, identity, identity);
-}
-
-/**
- * Copy the given tree, apply the corresponding transformation specified in the
- * TreeMapper to each node, and return the resulting tree.
- */
-function mapTree(
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic.
- tree: Tree,
- shape: Shape,
- path: Path,
- mappers: TreeMapper,
- // @ts-expect-error - TS2315 - Type 'Tree' is not generic.
-): Tree {
- // We trust the shape of the multi-item to match the shape provided at
- // runtime. Therefore, in each shape branch, we cast the node to `any` and
- // reinterpret it as the expected node type.
- if (shape.type === "content") {
- const content: CI = tree as any;
- return mappers.content(content, shape, path);
- }
- if (shape.type === "hint") {
- const hint: HI = tree as any;
- return mappers.hint(hint, shape, path);
- }
- if (shape.type === "tags") {
- const tags: TI = tree as any;
- return mappers.tags(tags, shape, path);
- }
- if (shape.type === "array") {
- // @ts-expect-error - TS2315 - Type 'ArrayNode' is not generic.
- const array: ArrayNode = tree as any;
-
- if (!Array.isArray(array)) {
- throw new PerseusError(
- `Invalid object of type "${typeof array}" found at path ` +
- // @ts-expect-error - TS2769 - No overload matches this call.
- `${[""].concat(path).join(".")}. Expected array.`,
- Errors.Internal,
- );
- }
-
- const elementShape = shape.elementShape;
- // @ts-expect-error - TS2315 - Type 'ArrayNode' is not generic.
- const mappedElements: ArrayNode = array.map((inner, i) =>
- mapTree(inner, elementShape, path.concat(i), mappers),
- );
- return mappers.array(mappedElements, array, shape, path);
- }
- if (shape.type === "object") {
- const object: ObjectNode = tree as any;
-
- if (object && typeof object !== "object") {
- throw new PerseusError(
- `Invalid object of type "${typeof object}" found at ` +
- // @ts-expect-error - TS2769 - No overload matches this call.
- `path ${[""].concat(path).join(".")}. Expected ` +
- `"object" type.`,
- Errors.InvalidInput,
- );
- }
-
- const valueShapes = shape.shape;
- if (!valueShapes) {
- throw new PerseusError(
- `Unexpected shape ${JSON.stringify(shape)} at path ` +
- // @ts-expect-error - TS2769 - No overload matches this call.
- `${[""].concat(path).join(".")}.`,
- Errors.InvalidInput,
- );
- }
- const newObject: Record = {};
- Object.keys(valueShapes).forEach((key) => {
- if (!(key in object)) {
- throw new PerseusError(
- `Key "${key}" is missing from shape at path ` +
- // @ts-expect-error - TS2769 - No overload matches this call.
- `${[""].concat(path).join(".")}.`,
- Errors.InvalidInput,
- );
- }
-
- newObject[key] = mapTree(
- object[key],
- valueShapes[key],
- path.concat(key),
- mappers,
- );
- });
- return newObject;
- }
- throw new PerseusError(
- // @ts-expect-error - TS2339 - Property 'type' does not exist on type 'never'.
- `unexpected shape type ${shape.type}`,
- Errors.InvalidInput,
- );
-}
diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts
index d6b73bc3c3..630ef58abc 100644
--- a/packages/perseus/src/perseus-types.ts
+++ b/packages/perseus/src/perseus-types.ts
@@ -1,14 +1,40 @@
-// TODO(FEI-4010): Remove `Perseus` prefix for all types here
-// TODO(FEI-4011): Use types generated by https://github.com/jaredly/generate-perseus-flowtypes
+/**
+ * The Perseus "data schema" file.
+ *
+ * This file, and the types in it, represents the "data schema" that Perseus
+ * uses. The @khanacademy/perseus-editor package edits and produces objects
+ * that conform to the types in this file. Similarly, the top-level renderers
+ * in @khanacademy/perseus, consume objects that conform to these types.
+ *
+ * WARNING: This file should not import any types from elsewhere so that it is
+ * easy to reason about changes that alter the Perseus schema. This helps
+ * ensure that it is not changed accidentally when upgrading a dependant
+ * package or other part of Perseus code. Note that TypeScript does type
+ * checking via something called "structural typing". This means that as long
+ * as the shape of a type matches, the name it goes by doesn't matter. As a
+ * result, a `Coord` type that looks like this `[x: number, y: number]` is
+ * _identical_, in TypeScript's eyes, to this `Vector2` type `[x: number, y:
+ * number]`. Also, with tuples, the labels for each entry is ignored, so `[x:
+ * number, y: number]` is compatible with `[min: number, max: number]`. The
+ * labels are for humans, not TypeScript. :)
+ *
+ * If you make changes to types in this file, be very sure that:
+ *
+ * a) the changes are backwards compatible. If they are not, old data from
+ * previous versions of the "schema" could become unrenderable, or worse,
+ * introduce hard-to-diagnose bugs.
+ * b) the parsing code (`util/parse-perseus-json/`) is updated to handle
+ * the new format _as well as_ the old format.
+ */
-import type {Coord} from "./interactive2/types";
-import type {Interval, vec} from "mafs";
+// TODO(FEI-4010): Remove `Perseus` prefix for all types here
-// Range is replaced within this file with Interval, but it is used elsewhere
-// and exported from the package, so we need to keep it around.
+export type Coord = [x: number, y: number];
+export type Interval = [min: number, max: number];
+export type Vector2 = Coord; // Same name as Mafs
export type Range = Interval;
-export type Size = [number, number];
-export type CollinearTuple = [vec.Vector2, vec.Vector2];
+export type Size = [width: number, height: number];
+export type CollinearTuple = [Vector2, Vector2];
export type ShowSolutions = "all" | "selected" | "none";
/**
@@ -140,19 +166,6 @@ export type PerseusItem = {
*/
export type PerseusArticle = PerseusRenderer | ReadonlyArray;
-/**
- * A "MultiItem" is an advanced Perseus item. It is rendered by the
- * `MultiRenderer` and you can control the layout of individual parts of the
- * item.
- *
- * @deprecated MultiItem support is slated for removal in a future Perseus
- * release.
- */
-export type MultiItem = {
- // Multi-item should only show up in Test Prep content and it is a variant of a PerseusItem
- _multi: any;
-};
-
export type Version = {
// The major part of the version
major: number;
diff --git a/packages/perseus/src/renderability.ts b/packages/perseus/src/renderability.ts
index 2cda9a0b22..910eaeba22 100644
--- a/packages/perseus/src/renderability.ts
+++ b/packages/perseus/src/renderability.ts
@@ -11,14 +11,11 @@
import {Errors, PerseusError} from "@khanacademy/perseus-core";
import _ from "underscore";
-import MultiItems from "./multi-items";
import {traverse} from "./traversal";
import * as Widgets from "./widgets";
import type {PerseusWidget} from "./perseus-types";
-const {findContentNodesInItem, inferItemShape} = MultiItems;
-
const isUpgradedWidgetInfoRenderableBy = function (
widgetInfo: PerseusWidget,
widgetRendererVersion: any,
@@ -86,21 +83,6 @@ export const isItemRenderableByVersion = function (
Errors.InvalidInput,
);
}
- if (itemData._multi) {
- const shape = inferItemShape(itemData);
-
- let isRenderable = true;
- findContentNodesInItem(itemData, shape, (node) => {
- const nodeIsRenderable = isRendererContentRenderableBy(
- node,
- rendererContentVersion,
- );
- if (!nodeIsRenderable) {
- isRenderable = false;
- }
- });
- return isRenderable;
- }
return isRendererContentRenderableBy(
itemData.question,
rendererContentVersion,
diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts
index 7ba9d2d385..e686ea0a00 100644
--- a/packages/perseus/src/types.ts
+++ b/packages/perseus/src/types.ts
@@ -1,5 +1,4 @@
import type {ILogger} from "./logging/log";
-import type {Item} from "./multi-items/item-types";
import type {
Hint,
PerseusAnswerArea,
@@ -138,8 +137,6 @@ export type ChangeHandler = (
question?: any;
answerArea?: PerseusAnswerArea | null;
itemDataVersion?: Version;
- // used in MutirenderEditor
- item?: Item;
editorMode?: EditorMode;
jsonMode?: boolean;
// perseus-all-package/widgets/unit.jsx
diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap b/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap
index ec368c0e50..55689676e2 100644
--- a/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap
+++ b/packages/perseus/src/util/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-snapshot.test.ts.snap
@@ -1608,34 +1608,6 @@ exports[`parseAndTypecheckPerseusItem correctly parses data/interactive-graph-wi
}
`;
-exports[`parseAndTypecheckPerseusItem correctly parses data/item-missing-answerArea.json 1`] = `
-{
- "_multi": {
- "blurb": {
- "__type": "content",
- "content": "",
- "images": {},
- "widgets": {},
- },
- "hints": [],
- "question": {
- "__type": "content",
- "content": "",
- "widgets": {},
- },
- },
- "answer": undefined,
- "answerArea": {},
- "hints": [],
- "itemDataVersion": undefined,
- "question": {
- "content": "",
- "images": {},
- "widgets": {},
- },
-}
-`;
-
exports[`parseAndTypecheckPerseusItem correctly parses data/matrix-missing-version.json 1`] = `
{
"answer": undefined,
diff --git a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/item-missing-answerArea.json b/packages/perseus/src/util/parse-perseus-json/regression-tests/data/item-missing-answerArea.json
deleted file mode 100644
index 41e1dbc06a..0000000000
--- a/packages/perseus/src/util/parse-perseus-json/regression-tests/data/item-missing-answerArea.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "_multi": {
- "blurb": {
- "__type": "content",
- "content": "",
- "images": {},
- "widgets": {}
- },
- "hints": [],
- "question": {
- "__type": "content",
- "content": "",
- "widgets": {}
- }
- }
-}
diff --git a/packages/perseus/src/widget-type-utils.ts b/packages/perseus/src/widget-type-utils.ts
index 3cc315ab7f..6bac814b6d 100644
--- a/packages/perseus/src/widget-type-utils.ts
+++ b/packages/perseus/src/widget-type-utils.ts
@@ -116,8 +116,6 @@ export function getWidgetFromWidgetMap(
/**
* Get select widgets from a widget map.
- * Useful for multi-items that needs to split
- * one PerseusItem into several
*
* @param {ReadonlyArray} widgetIds to extract
* @param {PerseusWidgetsMap} widgetMap to extract from
diff --git a/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap b/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap
index 915a4ac053..3b1723f09f 100644
--- a/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap
+++ b/packages/perseus/src/widgets/dropdown/__snapshots__/dropdown.test.ts.snap
@@ -37,7 +37,6 @@ exports[`Dropdown widget should snapshot when opened: dropdown open 1`] = `
aria-expanded="true"
aria-haspopup="listbox"
aria-label="Select an answer"
- aria-labelledby="uid-dropdown-widget-2-dropdown-label"
class="button_vr44p2-o_O-shared_u51dsh-o_O-default_3ie67y"
id="uid-dropdown-widget-2-dropdown"
role="combobox"
@@ -110,7 +109,6 @@ exports[`Dropdown widget should snapshot: initial render 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Select an answer"
- aria-labelledby="uid-dropdown-widget-0-dropdown-label"
class="button_vr44p2-o_O-shared_u51dsh-o_O-default_3ie67y"
id="uid-dropdown-widget-0-dropdown"
role="combobox"
diff --git a/packages/perseus/src/widgets/dropdown/dropdown.stories.tsx b/packages/perseus/src/widgets/dropdown/dropdown.stories.tsx
index 6c994dad24..a2ff15c7c6 100644
--- a/packages/perseus/src/widgets/dropdown/dropdown.stories.tsx
+++ b/packages/perseus/src/widgets/dropdown/dropdown.stories.tsx
@@ -3,6 +3,7 @@ import * as React from "react";
import {RendererWithDebugUI} from "../../../../../testing/renderer-with-debug-ui";
import {
+ dropdownWithEmptyPlaceholder,
dropdownWithVisibleLabel,
inlineDropdownWithVisibleLabel,
question1,
@@ -29,3 +30,9 @@ export const InlineDropdownWithVisibleLabel = (
): React.ReactElement => {
return ;
};
+
+export const DropdownWithEmptyPlaceholder = (
+ args: StoryArgs,
+): React.ReactElement => {
+ return ;
+};
diff --git a/packages/perseus/src/widgets/dropdown/dropdown.testdata.ts b/packages/perseus/src/widgets/dropdown/dropdown.testdata.ts
index 9f0ef14f25..b98d5cdbc4 100644
--- a/packages/perseus/src/widgets/dropdown/dropdown.testdata.ts
+++ b/packages/perseus/src/widgets/dropdown/dropdown.testdata.ts
@@ -150,3 +150,35 @@ export const inlineDropdownWithVisibleLabel: PerseusRenderer = {
},
},
};
+
+export const dropdownWithEmptyPlaceholder: PerseusRenderer = {
+ content:
+ "The total number of boxes the forklift can carry is [[☃ dropdown 1]] $60$.",
+ images: {},
+ widgets: {
+ "dropdown 1": {
+ type: "dropdown",
+ alignment: "default",
+ static: false,
+ graded: true,
+ options: {
+ static: false,
+ placeholder: "",
+ choices: [
+ {
+ content: "greater than or equal to",
+ correct: false,
+ },
+ {
+ content: "less than or equal to",
+ correct: true,
+ },
+ ],
+ },
+ version: {
+ major: 0,
+ minor: 0,
+ },
+ },
+ },
+};
diff --git a/packages/perseus/src/widgets/dropdown/dropdown.tsx b/packages/perseus/src/widgets/dropdown/dropdown.tsx
index 2fdaa3dae4..487ff0a364 100644
--- a/packages/perseus/src/widgets/dropdown/dropdown.tsx
+++ b/packages/perseus/src/widgets/dropdown/dropdown.tsx
@@ -74,7 +74,7 @@ class Dropdown extends React.Component implements Widget {
key="placeholder"
value="0"
disabled
- label={this.props.placeholder}
+ label={this.props.placeholder || " "}
/>,
...this.props.choices.map((choice, i) => (
implements Widget {
{this.props.visibleLabel && (
{this.props.visibleLabel}
@@ -116,9 +116,9 @@ class Dropdown extends React.Component implements Widget {
disabled={this.props.apiOptions.readOnly}
aria-label={
this.props.ariaLabel ||
+ this.props.visibleLabel ||
this.context.strings.selectAnAnswer
}
- aria-labelledby={ids.get("dropdown-label")}
// This is currently necessary for SRs to read the labels properly.
// However, WB is working on a change to add the "combobox" role to
// all dropdowns.
diff --git a/testing/multi-item-renderer-with-debug-ui.tsx b/testing/multi-item-renderer-with-debug-ui.tsx
deleted file mode 100644
index 08ec0e9e42..0000000000
--- a/testing/multi-item-renderer-with-debug-ui.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import Button from "@khanacademy/wonder-blocks-button";
-import {View} from "@khanacademy/wonder-blocks-core";
-import * as React from "react";
-
-import {MultiItems} from "../packages/perseus/src/index";
-import {simpleQuestionShape} from "../packages/perseus/src/multi-items/__testdata__/multi-renderer.testdata";
-
-import KEScoreUI from "./ke-score-ui";
-import SideBySide from "./side-by-side";
-import {storybookDependenciesV2} from "./test-dependencies";
-
-import type {Item as MultiItem} from "../packages/perseus/src/multi-items/item-types";
-import type {APIOptions} from "../packages/perseus/src/types";
-import type {KEScore} from "@khanacademy/perseus-core";
-
-type SimpleItemRenderTree = {
- blurb: React.ReactNode;
- question: React.ReactNode;
- hints: ReadonlyArray;
-};
-
-type Props = {
- simpleItem: MultiItem;
- children: (tree: {
- renderers: SimpleItemRenderTree;
- }) => React.ReactElement;
- apiOptions?: APIOptions;
-};
-
-// Renders an assessment item (aka {_multi: ...} that conforms to the
-// sample data simpleQuestionShape.
-export const MultiItemRendererWithDebugUI = ({
- children,
- simpleItem,
- apiOptions,
-}: Props): React.ReactElement => {
- // @ts-expect-error - TS2530 - Cannot find namespace 'MultiItems'.
- const ref = React.useRef(null);
- const [state, setState] = React.useState(null);
-
- return (
-
-
- {(renderers) => {
- return children(renderers);
- }}
-
-
-
-
- {
- if (!ref.current) {
- return;
- }
- setState(ref.current.score());
- }}
- >
- Check
-
-
-
- >
- }
- jsonObject={simpleItem}
- />
- );
-};
diff --git a/yarn.lock b/yarn.lock
index 8e94626cfb..2ba3f5266c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5138,9 +5138,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001541, caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001669:
- version "1.0.30001685"
- resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001685.tgz"
- integrity sha512-e/kJN1EMyHQzgcMEEgoo+YTCO1NGCmIYHk5Qk8jT6AazWemS5QFKJ5ShCJlH3GZrNIdZofcNCEwZqbMjjKzmnA==
+ version "1.0.30001687"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz"
+ integrity sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==
caseless@~0.12.0:
version "0.12.0"