@khanacademy/perseus-editor 2.16.2 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Perseus editors",
4
4
  "author": "Khan Academy",
5
5
  "license": "MIT",
6
- "version": "2.16.2",
6
+ "version": "2.17.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -25,7 +25,7 @@
25
25
  "@khanacademy/kas": "^0.3.7",
26
26
  "@khanacademy/kmath": "^0.1.8",
27
27
  "@khanacademy/math-input": "^16.5.0",
28
- "@khanacademy/perseus": "^17.7.0",
28
+ "@khanacademy/perseus": "^17.8.0",
29
29
  "@khanacademy/perseus-core": "1.4.1"
30
30
  },
31
31
  "devDependencies": {
@@ -1,120 +1,142 @@
1
- import {ApiOptions, Dependencies, Widgets} from "@khanacademy/perseus";
1
+ import {
2
+ ApiOptions,
3
+ Dependencies,
4
+ Widgets,
5
+ widgets,
6
+ Util,
7
+ } from "@khanacademy/perseus";
2
8
  import {render, screen} from "@testing-library/react";
3
9
  import userEvent from "@testing-library/user-event";
4
10
  import * as React from "react";
5
11
  import "@testing-library/jest-dom"; // Imports custom mathers
6
12
 
7
13
  import {testDependencies} from "../../../../testing/test-dependencies";
8
- import {wait} from "../../../../testing/wait";
9
- import {question1} from "../__testdata__/input-number.testdata";
10
14
  import Editor from "../editor";
15
+ import ImageEditor from "../widgets/image-editor";
16
+
17
+ import type {PropsFor} from "@khanacademy/wonder-blocks-core";
18
+
19
+ const Harnessed = (props: Partial<PropsFor<typeof Editor>>) => {
20
+ return (
21
+ <Editor
22
+ apiOptions={ApiOptions.defaults}
23
+ onChange={() => {}}
24
+ content="[[☃ image 1]]"
25
+ widgets={{
26
+ "image 1": {
27
+ type: "image",
28
+ options: {
29
+ backgroundImage: {
30
+ url: "http://placekitten.com/200/300",
31
+ },
32
+ },
33
+ },
34
+ }}
35
+ {...props}
36
+ />
37
+ );
38
+ };
11
39
 
12
40
  describe("Editor", () => {
41
+ beforeAll(() => {
42
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
43
+ const ImageWidget = widgets.find((w) => w.name === "image")!;
44
+ expect(ImageWidget).toBeDefined();
45
+ Widgets.registerWidget("image", ImageWidget);
46
+ Widgets.registerEditors([ImageEditor]);
47
+ });
48
+
13
49
  beforeEach(() => {
14
50
  jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
15
51
  testDependencies,
16
52
  );
17
53
  });
18
54
 
19
- describe("input-number widget", () => {
20
- beforeEach(async () => {
21
- const [InputNumberWidget, InputNumberEditor] = await Promise.all([
22
- import("@khanacademy/perseus").then((m) => m.InputNumber),
23
- import("../widgets/input-number-editor").then((m) => m.default),
24
- ]);
25
- Widgets.registerWidgets([InputNumberWidget]);
26
- Widgets.registerEditors([InputNumberEditor]);
27
- jest.useRealTimers();
55
+ it("should delete widget if confirmed", async () => {
56
+ const onChangeMock = jest.fn();
57
+ jest.spyOn(window, "confirm").mockReturnValue(true);
58
+
59
+ // Arrange
60
+ render(<Harnessed onChange={onChangeMock} />);
61
+
62
+ // Act
63
+ userEvent.click(
64
+ screen.getByRole("button", {name: "Remove image widget"}),
65
+ );
66
+
67
+ // Assert
68
+ expect(onChangeMock).toHaveBeenCalledWith({content: ""});
69
+ });
70
+
71
+ it("should NOT delete widget if not confirmed", async () => {
72
+ const onChangeMock = jest.fn();
73
+ jest.spyOn(window, "confirm").mockReturnValue(false);
74
+
75
+ // Arrange
76
+ render(<Harnessed onChange={onChangeMock} />);
77
+
78
+ // Act
79
+ userEvent.click(
80
+ screen.getByRole("button", {name: "Remove image widget"}),
81
+ );
82
+
83
+ // Assert
84
+ expect(onChangeMock).not.toHaveBeenCalled();
85
+ });
86
+
87
+ test("clicking on the widget editor should open it", async () => {
88
+ // Arrange
89
+ render(<Harnessed />);
90
+
91
+ // Act
92
+ const widgetDisclosure = screen.getByRole("link", {
93
+ name: "image 1",
28
94
  });
95
+ userEvent.click(widgetDisclosure);
96
+
97
+ // Assert
98
+ const previewImage = screen.getByAltText("Editor preview of image");
99
+ expect(previewImage).toHaveAttribute(
100
+ "src",
101
+ "http://placekitten.com/200/300",
102
+ );
103
+ });
104
+
105
+ it("should update values", async () => {
106
+ // Arrange
107
+ jest.spyOn(Util, "getImageSizeModern").mockResolvedValue([200, 200]);
29
108
 
30
- test("clicking on the widget editor should open it", async () => {
31
- // Arrange
32
- render(
33
- <Editor
34
- apiOptions={ApiOptions.defaults}
35
- content={question1.content}
36
- placeholder=""
37
- widgets={question1.widgets}
38
- images={question1.images}
39
- disabled={false}
40
- widgetEnabled={true}
41
- immutableWidgets={false}
42
- showWordCount={true}
43
- warnNoPrompt={true}
44
- warnNoWidgets={true}
45
- onChange={(props) => {}}
46
- />,
47
- );
48
- await wait();
49
-
50
- // Act
51
- const widgetDisclosure = screen.getByRole("link", {
52
- name: "input-number 1",
53
- });
54
- userEvent.click(widgetDisclosure);
55
- const correctAnswerInput = screen.getByLabelText("Correct answer:");
56
-
57
- // Assert
58
- expect(correctAnswerInput).toHaveValue("0.5");
109
+ const changeFn = jest.fn();
110
+ render(<Harnessed onChange={changeFn} />);
111
+
112
+ // Act
113
+ const widgetDisclosure = screen.getByRole("link", {
114
+ name: "image 1",
59
115
  });
116
+ userEvent.click(widgetDisclosure);
60
117
 
61
- it("should update values", async () => {
62
- // Arrange
63
- const changeFn = jest.fn();
64
- render(
65
- <Editor
66
- apiOptions={ApiOptions.defaults}
67
- content={question1.content}
68
- placeholder=""
69
- widgets={question1.widgets}
70
- images={question1.images}
71
- disabled={false}
72
- widgetEnabled={true}
73
- immutableWidgets={false}
74
- showWordCount={true}
75
- warnNoPrompt={true}
76
- warnNoWidgets={true}
77
- onChange={changeFn}
78
- />,
79
- );
80
- await wait();
81
-
82
- // Act
83
- const widgetDisclosure = screen.getByRole("link", {
84
- name: "input-number 1",
85
- });
86
- userEvent.click(widgetDisclosure);
87
- const correctAnswerInput = screen.getByLabelText("Correct answer:");
88
-
89
- userEvent.clear(correctAnswerInput);
90
- userEvent.paste(correctAnswerInput, "0.75");
91
- userEvent.tab(); // blurring the input triggers onChange to be called
92
-
93
- // Assert
94
- expect(changeFn).toHaveBeenCalledWith(
95
- {
96
- widgets: {
97
- "input-number 1": {
98
- graded: true,
99
- version: {major: 0, minor: 0},
100
- static: false,
101
- type: "input-number",
102
- options: {
103
- value: 0.75,
104
- simplify: "required",
105
- size: "normal",
106
- inexact: false,
107
- maxError: 0.1,
108
- answerType: "number",
109
- rightAlign: false,
110
- },
111
- alignment: "default",
112
- },
113
- },
118
+ const captionInput = screen.getByLabelText(/Caption:/);
119
+
120
+ userEvent.clear(captionInput);
121
+ userEvent.type(captionInput, "A picture of kittens");
122
+ userEvent.tab(); // blurring the input triggers onChange to be called
123
+ jest.runOnlyPendingTimers();
124
+
125
+ // Assert
126
+ expect(changeFn).toHaveBeenCalledWith(
127
+ {
128
+ widgets: {
129
+ "image 1": expect.objectContaining({
130
+ type: "image",
131
+ graded: true,
132
+ options: expect.objectContaining({
133
+ caption: "A picture of kittens",
134
+ }),
135
+ }),
114
136
  },
115
- undefined,
116
- undefined,
117
- );
118
- });
137
+ },
138
+ undefined,
139
+ undefined,
140
+ );
119
141
  });
120
142
  });
@@ -0,0 +1,83 @@
1
+ import "@testing-library/jest-dom";
2
+ import {render, screen} from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as React from "react";
5
+
6
+ import CombinedHintsEditor from "../hint-editor";
7
+
8
+ describe("CombinedHintsEditor", () => {
9
+ it("should render", () => {
10
+ render(
11
+ <CombinedHintsEditor deviceType="phone" previewURL="about:blank" />,
12
+ );
13
+ });
14
+
15
+ it("should confirm before removing a hint", () => {
16
+ // Arrange
17
+ const comfirmSpy = jest.spyOn(window, "confirm").mockReturnValue(false);
18
+ render(
19
+ <CombinedHintsEditor
20
+ deviceType="phone"
21
+ previewURL="about:blank"
22
+ hints={[
23
+ {content: "You know this one!", widgets: {}, images: {}},
24
+ {content: "Ok, the answer is 3", widgets: {}, images: {}},
25
+ ]}
26
+ />,
27
+ );
28
+
29
+ // Act
30
+ userEvent.click(screen.getAllByText("Remove this hint")[0]);
31
+
32
+ // Assert
33
+ expect(comfirmSpy).toHaveBeenCalled();
34
+ });
35
+
36
+ it("should not remove the hint if not confirmed", () => {
37
+ // Arrange
38
+ jest.spyOn(window, "confirm").mockReturnValue(false);
39
+ const onChangeMock = jest.fn();
40
+ render(
41
+ <CombinedHintsEditor
42
+ deviceType="phone"
43
+ previewURL="about:blank"
44
+ hints={[
45
+ {content: "You know this one!", widgets: {}, images: {}},
46
+ {content: "Ok, the answer is 3", widgets: {}, images: {}},
47
+ ]}
48
+ onChange={onChangeMock}
49
+ />,
50
+ );
51
+
52
+ // Act
53
+ userEvent.click(screen.getAllByText("Remove this hint")[0]);
54
+
55
+ // Assert
56
+ expect(onChangeMock).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("should remove the hint if confirmed", () => {
60
+ // Arrange
61
+ jest.spyOn(window, "confirm").mockReturnValue(true);
62
+ const onChangeMock = jest.fn();
63
+ render(
64
+ <CombinedHintsEditor
65
+ deviceType="phone"
66
+ previewURL="about:blank"
67
+ hints={[
68
+ {content: "You know this one!", widgets: {}, images: {}},
69
+ {content: "Ok, the answer is 3", widgets: {}, images: {}},
70
+ ]}
71
+ onChange={onChangeMock}
72
+ />,
73
+ );
74
+
75
+ // Act
76
+ userEvent.click(screen.getAllByText("Remove this hint")[0]);
77
+
78
+ // Assert
79
+ expect(onChangeMock).toHaveBeenCalledWith({
80
+ hints: [{content: "Ok, the answer is 3", widgets: {}, images: {}}],
81
+ });
82
+ });
83
+ });
package/src/editor.tsx CHANGED
@@ -285,6 +285,11 @@ class Editor extends React.Component<Props, State> {
285
285
  };
286
286
 
287
287
  _handleWidgetEditorRemove: (id: string) => void = (id: string) => {
288
+ // eslint-disable-next-line no-alert
289
+ if (!confirm("Are you sure you want to delete this item?")) {
290
+ return;
291
+ }
292
+
288
293
  const textarea = this.textarea.current;
289
294
  const re = new RegExp(widgetRegExp.replace("{id}", id), "gm");
290
295
  this.props.onChange({content: textarea?.value.replace(re, "")});
@@ -905,6 +910,8 @@ class Editor extends React.Component<Props, State> {
905
910
  underlayPieces = [];
906
911
 
907
912
  for (let i = 0; i < pieces.length; i++) {
913
+ // We split on widgets so every even-numbered indexed piece is
914
+ // text and odd-numbered indexes are the widget references.
908
915
  if (i % 2 === 0) {
909
916
  // Normal text
910
917
  underlayPieces.push(pieces[i]);
@@ -334,6 +334,11 @@ class CombinedHintsEditor extends React.Component<CombinedHintsEditorProps> {
334
334
  };
335
335
 
336
336
  handleHintRemove: (i: number) => void = (i: number) => {
337
+ // eslint-disable-next-line no-alert
338
+ if (!confirm("Are you sure you want to delete this hint?")) {
339
+ return;
340
+ }
341
+
337
342
  const hints = _(this.props.hints).clone();
338
343
  // @ts-expect-error - TS2339 - Property 'splice' does not exist on type 'Hint'.
339
344
  hints.splice(i, 1);