@khanacademy/perseus 12.1.0 → 12.2.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/CHANGELOG.md +8 -0
- package/dist/article-renderer.d.ts +2 -0
- package/dist/es/index.css +3 -0
- package/dist/es/index.js +28 -23
- package/dist/es/index.js.map +1 -1
- package/dist/index.css +3 -0
- package/dist/index.js +28 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__stories__/article-renderer.stories.tsx +28 -2
- package/src/__testdata__/article-renderer.testdata.ts +85 -0
- package/src/__tests__/article-renderer.test.tsx +131 -0
- package/src/article-renderer.tsx +58 -45
- package/src/styles/perseus-renderer.less +1 -0
- package/tsconfig-build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
multiSectionArticle,
|
|
8
8
|
passageArticle,
|
|
9
9
|
articleWithExpression,
|
|
10
|
+
multiSectionArticleWithExpression,
|
|
10
11
|
} from "../__testdata__/article-renderer.testdata";
|
|
11
12
|
import ArticleRenderer from "../article-renderer";
|
|
12
13
|
import TestKeypadContextWrapper from "../widgets/__stories__/test-keypad-context-wrapper";
|
|
@@ -55,7 +56,7 @@ export const PassageArticle = ({useNewStyles}): any => (
|
|
|
55
56
|
export const ExpressionArticle = ({useNewStyles}): any => (
|
|
56
57
|
<TestKeypadContextWrapper>
|
|
57
58
|
<KeypadContext.Consumer>
|
|
58
|
-
{({keypadElement, setRenderer
|
|
59
|
+
{({keypadElement, setRenderer}) => (
|
|
59
60
|
<ArticleRenderer
|
|
60
61
|
ref={(node) => {
|
|
61
62
|
setRenderer(node);
|
|
@@ -63,7 +64,32 @@ export const ExpressionArticle = ({useNewStyles}): any => (
|
|
|
63
64
|
json={articleWithExpression}
|
|
64
65
|
dependencies={storybookDependenciesV2}
|
|
65
66
|
useNewStyles={useNewStyles}
|
|
66
|
-
apiOptions={{
|
|
67
|
+
apiOptions={{
|
|
68
|
+
isMobile: true,
|
|
69
|
+
customKeypad: true,
|
|
70
|
+
}}
|
|
71
|
+
keypadElement={keypadElement}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
</KeypadContext.Consumer>
|
|
75
|
+
</TestKeypadContextWrapper>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export const MultiSectionedExpressionArticle = ({useNewStyles}): any => (
|
|
79
|
+
<TestKeypadContextWrapper>
|
|
80
|
+
<KeypadContext.Consumer>
|
|
81
|
+
{({keypadElement, setRenderer}) => (
|
|
82
|
+
<ArticleRenderer
|
|
83
|
+
ref={(node) => {
|
|
84
|
+
setRenderer(node);
|
|
85
|
+
}}
|
|
86
|
+
json={multiSectionArticleWithExpression}
|
|
87
|
+
dependencies={storybookDependenciesV2}
|
|
88
|
+
useNewStyles={useNewStyles}
|
|
89
|
+
apiOptions={{
|
|
90
|
+
isMobile: true,
|
|
91
|
+
customKeypad: true,
|
|
92
|
+
}}
|
|
67
93
|
keypadElement={keypadElement}
|
|
68
94
|
/>
|
|
69
95
|
)}
|
|
@@ -125,3 +125,88 @@ export const multiSectionArticle: ReadonlyArray<PerseusRenderer> = [
|
|
|
125
125
|
widgets: {},
|
|
126
126
|
},
|
|
127
127
|
];
|
|
128
|
+
|
|
129
|
+
export const multiSectionArticleWithExpression: ReadonlyArray<PerseusRenderer> =
|
|
130
|
+
[
|
|
131
|
+
{
|
|
132
|
+
content:
|
|
133
|
+
"### Practice Problem\n\n$8\\cdot(11i+2)=$ [[☃ expression 1]] \n*Your answer should be a complex number in the form $a+bi$ where $a$ and $b$ are real numbers.*",
|
|
134
|
+
images: {},
|
|
135
|
+
widgets: {
|
|
136
|
+
"expression 1": {
|
|
137
|
+
alignment: "default",
|
|
138
|
+
graded: true,
|
|
139
|
+
options: {
|
|
140
|
+
answerForms: [
|
|
141
|
+
{
|
|
142
|
+
considered: "correct",
|
|
143
|
+
form: true,
|
|
144
|
+
simplify: false,
|
|
145
|
+
value: "16+88i",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
buttonSets: ["basic"],
|
|
149
|
+
functions: ["f", "g", "h"],
|
|
150
|
+
times: false,
|
|
151
|
+
},
|
|
152
|
+
static: false,
|
|
153
|
+
type: "expression",
|
|
154
|
+
version: {major: 1, minor: 0},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
content:
|
|
160
|
+
"### Practice Problem\n\n$8\\cdot(11i+2)=$ [[☃ expression 2]] \n*Your answer should be a complex number in the form $a+bi$ where $a$ and $b$ are real numbers.*",
|
|
161
|
+
images: {},
|
|
162
|
+
widgets: {
|
|
163
|
+
"expression 2": {
|
|
164
|
+
alignment: "default",
|
|
165
|
+
graded: true,
|
|
166
|
+
options: {
|
|
167
|
+
answerForms: [
|
|
168
|
+
{
|
|
169
|
+
considered: "correct",
|
|
170
|
+
form: true,
|
|
171
|
+
simplify: false,
|
|
172
|
+
value: "16+88i",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
buttonSets: ["basic"],
|
|
176
|
+
functions: ["f", "g", "h"],
|
|
177
|
+
times: false,
|
|
178
|
+
},
|
|
179
|
+
static: false,
|
|
180
|
+
type: "expression",
|
|
181
|
+
version: {major: 1, minor: 0},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
content:
|
|
187
|
+
"### Practice Problem\n\n$8\\cdot(11i+2)=$ [[☃ expression 3]] \n*Your answer should be a complex number in the form $a+bi$ where $a$ and $b$ are real numbers.*",
|
|
188
|
+
images: {},
|
|
189
|
+
widgets: {
|
|
190
|
+
"expression 3": {
|
|
191
|
+
alignment: "default",
|
|
192
|
+
graded: true,
|
|
193
|
+
options: {
|
|
194
|
+
answerForms: [
|
|
195
|
+
{
|
|
196
|
+
considered: "correct",
|
|
197
|
+
form: true,
|
|
198
|
+
simplify: false,
|
|
199
|
+
value: "16+88i",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
buttonSets: ["basic"],
|
|
203
|
+
functions: ["f", "g", "h"],
|
|
204
|
+
times: false,
|
|
205
|
+
},
|
|
206
|
+
static: false,
|
|
207
|
+
type: "expression",
|
|
208
|
+
version: {major: 1, minor: 0},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
];
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StatefulKeypadContextProvider,
|
|
3
|
+
KeypadContext,
|
|
4
|
+
} from "@khanacademy/math-input";
|
|
5
|
+
import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
|
|
6
|
+
import {screen, render, fireEvent, waitFor} from "@testing-library/react";
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import "@testing-library/jest-dom"; // Imports custom matchers
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
testDependencies,
|
|
12
|
+
testDependenciesV2,
|
|
13
|
+
} from "../../../../testing/test-dependencies";
|
|
14
|
+
import KeypadSwitch from "../../../math-input/src/components/keypad-switch";
|
|
15
|
+
import {articleWithExpression} from "../__testdata__/article-renderer.testdata";
|
|
16
|
+
import ArticleRenderer from "../article-renderer";
|
|
17
|
+
import * as Dependencies from "../dependencies";
|
|
18
|
+
import {ApiOptions} from "../perseus-api";
|
|
19
|
+
|
|
20
|
+
import type {APIOptions} from "../types";
|
|
21
|
+
|
|
22
|
+
function KeypadWithContext() {
|
|
23
|
+
return (
|
|
24
|
+
<KeypadContext.Consumer>
|
|
25
|
+
{({setKeypadElement}) => {
|
|
26
|
+
return (
|
|
27
|
+
<KeypadSwitch
|
|
28
|
+
onElementMounted={setKeypadElement}
|
|
29
|
+
onDismiss={() => {}}
|
|
30
|
+
onAnalyticsEvent={async () => {}}
|
|
31
|
+
useV2Keypad
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}}
|
|
35
|
+
</KeypadContext.Consumer>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
// This looks alot like `widgets/__tests__/renderQuestion.jsx', except we use
|
|
39
|
+
// the ArticleRenderer instead of Renderer
|
|
40
|
+
export const RenderArticle = (
|
|
41
|
+
apiOptions: APIOptions = Object.freeze({}),
|
|
42
|
+
): {
|
|
43
|
+
container: HTMLElement;
|
|
44
|
+
renderer: ArticleRenderer;
|
|
45
|
+
} => {
|
|
46
|
+
let renderer: ArticleRenderer | null = null;
|
|
47
|
+
const {container} = render(
|
|
48
|
+
<RenderStateRoot>
|
|
49
|
+
<StatefulKeypadContextProvider>
|
|
50
|
+
<KeypadContext.Consumer>
|
|
51
|
+
{({keypadElement, setRenderer}) => (
|
|
52
|
+
<ArticleRenderer
|
|
53
|
+
ref={(node) => {
|
|
54
|
+
renderer = node;
|
|
55
|
+
setRenderer(node);
|
|
56
|
+
}}
|
|
57
|
+
json={articleWithExpression}
|
|
58
|
+
dependencies={testDependenciesV2}
|
|
59
|
+
apiOptions={{...apiOptions}}
|
|
60
|
+
keypadElement={keypadElement}
|
|
61
|
+
/>
|
|
62
|
+
)}
|
|
63
|
+
</KeypadContext.Consumer>
|
|
64
|
+
<KeypadWithContext />
|
|
65
|
+
</StatefulKeypadContextProvider>
|
|
66
|
+
</RenderStateRoot>,
|
|
67
|
+
);
|
|
68
|
+
if (!renderer) {
|
|
69
|
+
throw new Error(`Failed to render!`);
|
|
70
|
+
}
|
|
71
|
+
return {container, renderer};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("article renderer", () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
// Mock ResizeObserver used by the mobile keypad
|
|
77
|
+
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
78
|
+
observe: jest.fn(),
|
|
79
|
+
unobserve: jest.fn(),
|
|
80
|
+
disconnect: jest.fn(),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
|
|
84
|
+
testDependencies,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
// The Renderer uses a timer to wait for widgets to complete rendering.
|
|
90
|
+
// If we don't spin the timers here, then the timer fires in the test
|
|
91
|
+
// _after_ and breaks it because we do setState() in the callback,
|
|
92
|
+
// and by that point the component has been unmounted.
|
|
93
|
+
jest.runOnlyPendingTimers();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should render the content", () => {
|
|
97
|
+
// Arrange and Act
|
|
98
|
+
RenderArticle({
|
|
99
|
+
...ApiOptions.defaults,
|
|
100
|
+
isMobile: false,
|
|
101
|
+
customKeypad: false,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Assert
|
|
105
|
+
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should call the onFocusChanged callback when an input is focused", async () => {
|
|
109
|
+
// Arrange
|
|
110
|
+
const answerableCallback = jest.fn();
|
|
111
|
+
|
|
112
|
+
// Act
|
|
113
|
+
RenderArticle({
|
|
114
|
+
...ApiOptions.defaults,
|
|
115
|
+
onFocusChange: answerableCallback,
|
|
116
|
+
isMobile: true,
|
|
117
|
+
customKeypad: true,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const input = screen.getByLabelText(
|
|
121
|
+
"Math input box Tap with one or two fingers to open keyboard",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
fireEvent.touchStart(input);
|
|
125
|
+
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(screen.getByRole("button", {name: "4"})).toBeVisible();
|
|
128
|
+
});
|
|
129
|
+
expect(answerableCallback).toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/article-renderer.tsx
CHANGED
|
@@ -44,6 +44,7 @@ class ArticleRenderer
|
|
|
44
44
|
implements KeypadContextRendererInterface
|
|
45
45
|
{
|
|
46
46
|
_currentFocus: any;
|
|
47
|
+
sectionRenderers: Array<Renderer> = [];
|
|
47
48
|
|
|
48
49
|
static defaultProps: DefaultProps = {
|
|
49
50
|
apiOptions: ApiOptions.defaults,
|
|
@@ -87,14 +88,21 @@ class ArticleRenderer
|
|
|
87
88
|
// paths, so as to check whether the focused path represents an
|
|
88
89
|
// input.
|
|
89
90
|
let didFocusInput = false;
|
|
91
|
+
let focusedInput;
|
|
92
|
+
|
|
90
93
|
if (this._currentFocus) {
|
|
91
|
-
const [
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const [sectionIndex, ...focusPath] = this._currentFocus;
|
|
95
|
+
|
|
96
|
+
const inputPaths =
|
|
97
|
+
this.sectionRenderers[sectionIndex].getInputPaths();
|
|
98
|
+
|
|
95
99
|
didFocusInput = inputPaths.some((inputPath) => {
|
|
96
100
|
return Util.inputPathsEqual(inputPath, focusPath);
|
|
97
101
|
});
|
|
102
|
+
focusedInput =
|
|
103
|
+
this.sectionRenderers[sectionIndex].getDOMNodeForPath(
|
|
104
|
+
focusPath,
|
|
105
|
+
);
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
if (this.props.apiOptions.onFocusChange != null) {
|
|
@@ -102,6 +110,7 @@ class ArticleRenderer
|
|
|
102
110
|
this._currentFocus,
|
|
103
111
|
prevFocusPath,
|
|
104
112
|
didFocusInput ? keypadElement?.getDOMNode() : null,
|
|
113
|
+
didFocusInput ? focusedInput : null,
|
|
105
114
|
);
|
|
106
115
|
}
|
|
107
116
|
|
|
@@ -137,10 +146,8 @@ class ArticleRenderer
|
|
|
137
146
|
|
|
138
147
|
blur: () => void = () => {
|
|
139
148
|
if (this._currentFocus) {
|
|
140
|
-
const [
|
|
141
|
-
|
|
142
|
-
// @ts-expect-error - TS2339 - Property 'blurPath' does not exist on type 'ReactInstance'.
|
|
143
|
-
this.refs[sectionRef].blurPath(inputPath);
|
|
149
|
+
const [sectionIndex, ...inputPath] = this._currentFocus;
|
|
150
|
+
this.sectionRenderers[sectionIndex].blurPath(inputPath);
|
|
144
151
|
}
|
|
145
152
|
};
|
|
146
153
|
|
|
@@ -166,47 +173,53 @@ class ArticleRenderer
|
|
|
166
173
|
});
|
|
167
174
|
|
|
168
175
|
// TODO(alex): Add mobile api functions and pass them down here
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
// We're using the index as the key here because we don't have a unique
|
|
177
|
+
// identifier for each section. This should be fine as we never remove
|
|
178
|
+
// or reorder sections.
|
|
179
|
+
const sections = this._sections().map((section, sectionIndex) => {
|
|
171
180
|
return (
|
|
172
|
-
<
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
linterContext
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
</div>
|
|
205
|
-
</DependenciesContext.Provider>
|
|
181
|
+
<div key={sectionIndex} className="clearfix">
|
|
182
|
+
<Renderer
|
|
183
|
+
{...section}
|
|
184
|
+
ref={(elem) => {
|
|
185
|
+
if (elem) {
|
|
186
|
+
this.sectionRenderers[sectionIndex] = elem;
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
189
|
+
key={sectionIndex}
|
|
190
|
+
key_={sectionIndex}
|
|
191
|
+
keypadElement={this.props.keypadElement}
|
|
192
|
+
apiOptions={{
|
|
193
|
+
...apiOptions,
|
|
194
|
+
onFocusChange: (newFocusPath, oldFocusPath) => {
|
|
195
|
+
// Prefix the paths with the relevant section index,
|
|
196
|
+
// so as to allow us to distinguish between
|
|
197
|
+
// equivalently-named inputs across Renderers.
|
|
198
|
+
this._handleFocusChange(
|
|
199
|
+
newFocusPath &&
|
|
200
|
+
[sectionIndex].concat(newFocusPath),
|
|
201
|
+
oldFocusPath &&
|
|
202
|
+
[sectionIndex].concat(oldFocusPath),
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
}}
|
|
206
|
+
linterContext={PerseusLinter.pushContextStack(
|
|
207
|
+
this.props.linterContext,
|
|
208
|
+
"article",
|
|
209
|
+
)}
|
|
210
|
+
legacyPerseusLint={this.props.legacyPerseusLint}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
206
213
|
);
|
|
207
214
|
});
|
|
208
215
|
|
|
209
|
-
return
|
|
216
|
+
return (
|
|
217
|
+
<div className={classes}>
|
|
218
|
+
<DependenciesContext.Provider value={this.props.dependencies}>
|
|
219
|
+
{sections}
|
|
220
|
+
</DependenciesContext.Provider>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
210
223
|
}
|
|
211
224
|
}
|
|
212
225
|
|