@khanacademy/perseus 15.0.0 → 16.0.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.
@@ -11,7 +11,7 @@ import {
11
11
  import {
12
12
  itemWithInput,
13
13
  mockedItem,
14
- } from "../__testdata__/server-item-renderer.testdata";
14
+ } from "../__testdata__/item-renderer.testdata";
15
15
  import * as Dependencies from "../dependencies";
16
16
  import WrappedServerItemRenderer, {
17
17
  ServerItemRenderer,
@@ -12,25 +12,27 @@ import {ClassNames as ApiClassNames, ApiOptions} from "./perseus-api";
12
12
  import Renderer from "./renderer";
13
13
  import Util from "./util";
14
14
 
15
+ import type {KeypadProps} from "./mixins/provide-keypad";
15
16
  import type {PerseusRenderer} from "./perseus-types";
16
17
  import type {APIOptions, PerseusDependenciesV2} from "./types";
17
18
  import type {KeypadAPI} from "@khanacademy/math-input";
18
19
  import type {KeypadContextRendererInterface} from "@khanacademy/perseus-core";
19
20
  import type {LinterContextProps} from "@khanacademy/perseus-linter";
20
21
 
21
- type Props = Partial<React.ContextType<typeof DependenciesContext>> & {
22
- apiOptions: APIOptions;
23
- json: PerseusRenderer | ReadonlyArray<PerseusRenderer>;
24
- // Whether to use the new Bibliotron styles for articles
25
- /**
26
- * @deprecated Does nothing
27
- */
28
- useNewStyles: boolean;
29
- linterContext: LinterContextProps;
30
- legacyPerseusLint?: ReadonlyArray<string>;
31
- keypadElement?: KeypadAPI | null | undefined;
32
- dependencies: PerseusDependenciesV2;
33
- };
22
+ type Props = Partial<React.ContextType<typeof DependenciesContext>> &
23
+ KeypadProps & {
24
+ apiOptions: APIOptions;
25
+ json: PerseusRenderer | ReadonlyArray<PerseusRenderer>;
26
+ // Whether to use the new Bibliotron styles for articles
27
+ /**
28
+ * @deprecated Does nothing
29
+ */
30
+ useNewStyles: boolean;
31
+ linterContext: LinterContextProps;
32
+ legacyPerseusLint?: ReadonlyArray<string>;
33
+ keypadElement?: KeypadAPI | null | undefined;
34
+ dependencies: PerseusDependenciesV2;
35
+ };
34
36
 
35
37
  type DefaultProps = {
36
38
  apiOptions: Props["apiOptions"];
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {default as itemVersion} from "./item-version";
14
14
  * Renderers
15
15
  */
16
16
  export {default as ArticleRenderer} from "./article-renderer";
17
+ export {default as ItemRenderer} from "./item-renderer";
17
18
  export {default as ServerItemRenderer} from "./server-item-renderer";
18
19
  export {default as HintsRenderer} from "./hints-renderer";
19
20
  export {default as HintRenderer} from "./hint-renderer";
@@ -0,0 +1,529 @@
1
+ /* eslint-disable @khanacademy/ts-no-error-suppressions */
2
+ /* eslint-disable react/no-unsafe */
3
+ import * as PerseusLinter from "@khanacademy/perseus-linter";
4
+ import $ from "jquery";
5
+ import * as React from "react";
6
+ import * as ReactDOM from "react-dom";
7
+ import _ from "underscore";
8
+
9
+ import {DependenciesContext} from "./dependencies";
10
+ import HintsRenderer from "./hints-renderer";
11
+ import Objective from "./interactive2/objective_";
12
+ import ProvideKeypad from "./mixins/provide-keypad";
13
+ import {ApiOptions} from "./perseus-api";
14
+ import Renderer from "./renderer";
15
+ import Util from "./util";
16
+ import reactRender from "./util/react-render";
17
+
18
+ import type {KeypadProps} from "./mixins/provide-keypad";
19
+ import type {PerseusItem} from "./perseus-types";
20
+ import type {APIOptions, FocusPath, PerseusDependenciesV2} from "./types";
21
+ import type {KEScore} from "@khanacademy/perseus-core";
22
+ import type {LinterContextProps} from "@khanacademy/perseus-linter";
23
+
24
+ const {mapObject} = Objective;
25
+
26
+ type Props = // These props are used by the ProvideKeypad mixin.
27
+ KeypadProps & {
28
+ // defaults are set in `this.update()` so as to adhere to
29
+ // `ApiOptions.PropTypes`, though the API options that are passed in
30
+ // can be in any degree of completeness
31
+ apiOptions: APIOptions;
32
+ // Whether this component should control hiding/showing peripheral
33
+ // item-related components (for list, see item.answerArea below).
34
+ // TODO(alex): Generalize this to an 'expectsToBeInTemplate' prop
35
+ controlPeripherals?: boolean;
36
+ workAreaSelector: string;
37
+ hintsAreaSelector: string;
38
+ initialHintsVisible?: number;
39
+ item: PerseusItem | any /* any for _multi items */;
40
+ onShowCalculator?: () => unknown;
41
+ onShowChi2Table?: () => unknown;
42
+ onShowPeriodicTable?: (options?: {showKey: boolean}) => unknown;
43
+ onShowTTable?: () => unknown;
44
+ onShowZTable?: () => unknown;
45
+ problemNum: number;
46
+ reviewMode: boolean;
47
+ // TODO(kevinb): make this more precise
48
+ savedState: any;
49
+ linterContext: LinterContextProps;
50
+ legacyPerseusLint?: ReadonlyArray<string>;
51
+
52
+ dependencies: PerseusDependenciesV2;
53
+ };
54
+
55
+ type DefaultProps = {
56
+ apiOptions: Props["apiOptions"];
57
+ controlPeripherals: Props["controlPeripherals"];
58
+ hintsAreaSelector: Props["hintsAreaSelector"];
59
+ initialHintsVisible: Props["initialHintsVisible"];
60
+ linterContext: Props["linterContext"];
61
+ reviewMode: Props["reviewMode"];
62
+ workAreaSelector: Props["workAreaSelector"];
63
+ };
64
+
65
+ type State = {
66
+ keypadElement: HTMLElement | null | undefined; // from ProvideKeypad.getInitialState,
67
+ hintsVisible: number;
68
+ questionCompleted: boolean;
69
+ questionHighlightedWidgets: ReadonlyArray<string>; // of WidgetIDs
70
+ };
71
+
72
+ type SerializedState = {
73
+ hints: any;
74
+ question: {
75
+ [id: string]: any;
76
+ };
77
+ };
78
+
79
+ /**
80
+ * @depreciated use ServerItemRenderer
81
+ */
82
+ class ItemRenderer extends React.Component<Props, State> {
83
+ // @ts-expect-error - TS2564 - Property 'questionRenderer' has no initializer and is not definitely assigned in the constructor.
84
+ questionRenderer: Renderer;
85
+ hintsRenderer: React.ElementRef<typeof HintsRenderer> | null | undefined;
86
+ _currentFocus: FocusPath;
87
+
88
+ static defaultProps: DefaultProps = {
89
+ apiOptions: ApiOptions.defaults, // defaults are set in `this.update()`
90
+ controlPeripherals: true,
91
+ hintsAreaSelector: "#hintsarea",
92
+ initialHintsVisible: 0,
93
+ workAreaSelector: "#workarea",
94
+ reviewMode: false,
95
+ linterContext: PerseusLinter.linterContextDefault,
96
+ };
97
+
98
+ constructor(props: Props) {
99
+ super(props);
100
+
101
+ this.state = {
102
+ // NOTE: This value is used in provide-keypad.jsx which contains a
103
+ // "mixin" that this file calls methods on.
104
+ ...ProvideKeypad.getInitialState.call(this),
105
+ hintsVisible: props.initialHintsVisible || 0,
106
+ questionCompleted: false,
107
+ questionHighlightedWidgets: [],
108
+ };
109
+ }
110
+
111
+ componentDidMount() {
112
+ ProvideKeypad.componentDidMount.call(this);
113
+ if (
114
+ this.props.controlPeripherals &&
115
+ this.props.apiOptions.setDrawingAreaAvailable
116
+ ) {
117
+ this.props.apiOptions.setDrawingAreaAvailable(true);
118
+ }
119
+ this._currentFocus = null;
120
+ this.update();
121
+ }
122
+
123
+ UNSAFE_componentWillReceiveProps(nextProps: Props) {
124
+ this.setState({
125
+ questionHighlightedWidgets: [],
126
+ });
127
+ }
128
+
129
+ componentDidUpdate() {
130
+ this.update();
131
+ }
132
+
133
+ componentWillUnmount() {
134
+ ProvideKeypad.componentWillUnmount.call(this);
135
+ const workArea = document.querySelector(this.props.workAreaSelector);
136
+ if (workArea != null) {
137
+ ReactDOM.unmountComponentAtNode(workArea);
138
+ }
139
+ const hintsArea = document.querySelector(this.props.hintsAreaSelector);
140
+ if (hintsArea != null) {
141
+ ReactDOM.unmountComponentAtNode(hintsArea);
142
+ }
143
+
144
+ if (this.props.controlPeripherals) {
145
+ const answerArea = this.props.item.answerArea || {};
146
+ if (answerArea.calculator) {
147
+ $("#calculator").hide();
148
+ }
149
+ if (answerArea.periodicTable) {
150
+ $(".periodic-table-info-box").hide();
151
+ }
152
+ if (answerArea.zTable) {
153
+ $(".z-table-info-box").hide();
154
+ }
155
+ if (answerArea.tTable) {
156
+ $(".t-table-info-box").hide();
157
+ }
158
+ if (answerArea.chi2Table) {
159
+ $(".chi2-table-info-box").hide();
160
+ }
161
+ }
162
+ }
163
+
164
+ keypadElement(): any {
165
+ return ProvideKeypad.keypadElement.call(this);
166
+ }
167
+
168
+ update() {
169
+ const apiOptions = {
170
+ ...ApiOptions.defaults,
171
+ ...this.props.apiOptions,
172
+ onFocusChange: this._handleFocusChange,
173
+ } as const;
174
+
175
+ const workArea = document.querySelector(this.props.workAreaSelector);
176
+ const hintsArea = document.querySelector(this.props.hintsAreaSelector);
177
+ if (!workArea || !hintsArea) {
178
+ return;
179
+ }
180
+
181
+ // Since the item renderer works by rendering things into three divs
182
+ // that have completely different places in the DOM, we have to do this
183
+ // strangeness instead of relying on React's normal render() method.
184
+ // TODO(alpert): Figure out how to clean this up somehow
185
+ reactRender(
186
+ <DependenciesContext.Provider value={this.props.dependencies}>
187
+ <Renderer
188
+ ref={(node) => {
189
+ if (!node) {
190
+ return;
191
+ }
192
+ this.questionRenderer = node;
193
+
194
+ // NOTE(jeremy): Why don't we just pass this into the
195
+ // renderer as a prop?
196
+ const {answerableCallback} = apiOptions;
197
+ if (answerableCallback) {
198
+ const isAnswerable =
199
+ this.questionRenderer.emptyWidgets().length ===
200
+ 0;
201
+ answerableCallback(isAnswerable);
202
+ }
203
+ }}
204
+ keypadElement={this.keypadElement()}
205
+ problemNum={this.props.problemNum}
206
+ onInteractWithWidget={this.handleInteractWithWidget}
207
+ highlightedWidgets={this.state.questionHighlightedWidgets}
208
+ apiOptions={apiOptions}
209
+ questionCompleted={this.state.questionCompleted}
210
+ reviewMode={this.props.reviewMode}
211
+ savedState={this.props.savedState}
212
+ linterContext={PerseusLinter.pushContextStack(
213
+ this.props.linterContext,
214
+ "question",
215
+ )}
216
+ {...this.props.item.question}
217
+ legacyPerseusLint={this.props.legacyPerseusLint}
218
+ />
219
+ </DependenciesContext.Provider>,
220
+ // @ts-expect-error - TS2345 - Argument of type 'Element' is not assignable to parameter of type 'HTMLElement'.
221
+ workArea,
222
+ );
223
+
224
+ reactRender(
225
+ <DependenciesContext.Provider value={this.props.dependencies}>
226
+ <HintsRenderer
227
+ ref={(node) => (this.hintsRenderer = node)}
228
+ hints={this.props.item.hints}
229
+ hintsVisible={this.state.hintsVisible}
230
+ apiOptions={apiOptions}
231
+ linterContext={PerseusLinter.pushContextStack(
232
+ this.props.linterContext,
233
+ "hints",
234
+ )}
235
+ />
236
+ </DependenciesContext.Provider>,
237
+
238
+ // @ts-expect-error - TS2345 - Argument of type 'Element' is not assignable to parameter of type 'HTMLElement'.
239
+ hintsArea,
240
+ );
241
+
242
+ const answerArea = this.props.item.answerArea || {};
243
+ if (this.props.controlPeripherals) {
244
+ $("#calculator").toggle(answerArea.calculator || false);
245
+ $(".periodic-table-info-box").toggle(
246
+ answerArea.periodicTable || false,
247
+ );
248
+ $(".z-table-info-box").toggle(answerArea.zTable || false);
249
+ $(".t-table-info-box").toggle(answerArea.tTable || false);
250
+ $(".chi2-table-info-box").toggle(answerArea.chi2Table || false);
251
+ } else {
252
+ if (answerArea.calculator) {
253
+ this.props.onShowCalculator && this.props.onShowCalculator();
254
+ }
255
+ if (answerArea.periodicTable) {
256
+ this.props.onShowPeriodicTable &&
257
+ this.props.onShowPeriodicTable({
258
+ showKey: answerArea.periodicTableWithKey,
259
+ });
260
+ }
261
+ if (answerArea.zTable) {
262
+ this.props.onShowZTable && this.props.onShowZTable();
263
+ }
264
+ if (answerArea.tTable) {
265
+ this.props.onShowTTable && this.props.onShowTTable();
266
+ }
267
+ if (answerArea.chi2Table) {
268
+ this.props.onShowChi2Table && this.props.onShowChi2Table();
269
+ }
270
+ }
271
+ }
272
+
273
+ _handleFocusChange: (newFocus: FocusPath, oldFocus: FocusPath) => void = (
274
+ newFocus: FocusPath,
275
+ oldFocus: FocusPath,
276
+ ) => {
277
+ if (newFocus != null) {
278
+ this._setCurrentFocus(newFocus);
279
+ } else {
280
+ this._onRendererBlur(oldFocus);
281
+ }
282
+ };
283
+
284
+ // Sets the current focus path and element and send an onChangeFocus event
285
+ // back to our parent.
286
+ _setCurrentFocus(newFocus: FocusPath) {
287
+ const keypadElement = this.keypadElement();
288
+ // By the time this happens, newFocus cannot be a prefix of
289
+ // prevFocused, since we must have either been called from
290
+ // an onFocusChange within a renderer, which is only called when
291
+ // this is not a prefix, or between the question and answer areas,
292
+ // which can never prefix each other.
293
+ const prevFocus = this._currentFocus;
294
+ this._currentFocus = newFocus;
295
+
296
+ // Determine whether the newly focused path represents an input.
297
+ const inputPaths = this.getInputPaths();
298
+ const didFocusInput =
299
+ this._currentFocus &&
300
+ inputPaths.some((inputPath) => {
301
+ return Util.inputPathsEqual(inputPath, this._currentFocus);
302
+ });
303
+
304
+ if (this.props.apiOptions.onFocusChange != null) {
305
+ this.props.apiOptions.onFocusChange(
306
+ this._currentFocus,
307
+ prevFocus,
308
+ didFocusInput && keypadElement && keypadElement.getDOMNode(),
309
+ );
310
+ }
311
+
312
+ if (keypadElement) {
313
+ if (didFocusInput) {
314
+ keypadElement.activate();
315
+ } else {
316
+ keypadElement.dismiss();
317
+ }
318
+ }
319
+ }
320
+
321
+ _onRendererBlur(blurPath: FocusPath) {
322
+ const blurringFocusPath = this._currentFocus;
323
+
324
+ // Failsafe: abort if ID is different, because focus probably happened
325
+ // before blur.
326
+ if (!Util.inputPathsEqual(blurPath, blurringFocusPath)) {
327
+ return;
328
+ }
329
+
330
+ // Wait until after any new focus events fire this tick before
331
+ // declaring that nothing is focused, since if there were a focus change
332
+ // across Renderers (e.g., from the HintsRenderer to the
333
+ // QuestionRenderer), we could receive the blur before the focus.
334
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API
335
+ // eslint-disable-next-line no-restricted-syntax
336
+ setTimeout(() => {
337
+ if (Util.inputPathsEqual(this._currentFocus, blurringFocusPath)) {
338
+ this._setCurrentFocus(null);
339
+ }
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Accepts a question area widgetId, or an answer area widgetId of
345
+ * the form "answer-input-number 1", or the string "answer-area"
346
+ * for the whole answer area (if the answer area is a single widget).
347
+ */
348
+ _setWidgetProps(widgetId: string, newProps: any, callback: any) {
349
+ this.questionRenderer._setWidgetProps(widgetId, newProps, callback);
350
+ }
351
+
352
+ /**
353
+ * Sets the input value into the widget found at `path`. The `focus`
354
+ * function provided is called when all affected widgets have been
355
+ * re-rendered with the new value.
356
+ */
357
+ setInputValue(path: FocusPath, newValue: string, focus: () => void): void {
358
+ return this.questionRenderer.setInputValue(path, newValue, focus);
359
+ }
360
+
361
+ focusPath(path: FocusPath): void {
362
+ return this.questionRenderer.focusPath(path);
363
+ }
364
+
365
+ blurPath(path: FocusPath): void {
366
+ return this.questionRenderer.blurPath(path);
367
+ }
368
+
369
+ getDOMNodeForPath(path: FocusPath): Element | Text | null | undefined {
370
+ return this.questionRenderer.getDOMNodeForPath(path);
371
+ }
372
+
373
+ getGrammarTypeForPath(path: FocusPath): string | null | undefined {
374
+ return this.questionRenderer.getGrammarTypeForPath(path);
375
+ }
376
+
377
+ getInputPaths(): ReadonlyArray<FocusPath> {
378
+ const questionAreaInputPaths = this.questionRenderer.getInputPaths();
379
+ return questionAreaInputPaths;
380
+ }
381
+
382
+ // NOTE: this must remain as an arrow function because we pass it Renderer
383
+ handleInteractWithWidget: (widgetId: string) => void = (
384
+ widgetId: string,
385
+ ) => {
386
+ const withRemoved = _.difference(
387
+ this.state.questionHighlightedWidgets,
388
+ [widgetId],
389
+ );
390
+ this.setState({
391
+ questionCompleted: false,
392
+ questionHighlightedWidgets: withRemoved,
393
+ });
394
+
395
+ if (this.props.apiOptions.interactionCallback) {
396
+ this.props.apiOptions.interactionCallback();
397
+ }
398
+ };
399
+
400
+ focus(): boolean | null | undefined {
401
+ return this.questionRenderer.focus();
402
+ }
403
+
404
+ blur() {
405
+ if (this._currentFocus) {
406
+ this.blurPath(this._currentFocus);
407
+ }
408
+ }
409
+
410
+ showHint() {
411
+ if (this.state.hintsVisible < this.getNumHints()) {
412
+ this.setState({
413
+ hintsVisible: this.state.hintsVisible + 1,
414
+ });
415
+ }
416
+ }
417
+
418
+ getNumHints(): number {
419
+ return this.props.item.hints.length;
420
+ }
421
+
422
+ /**
423
+ * Grades the item.
424
+ *
425
+ * Returns a KE-style score of {
426
+ * empty: bool,
427
+ * correct: bool,
428
+ * message: string|null,
429
+ * guess: Array
430
+ * }
431
+ */
432
+ scoreInput(): KEScore {
433
+ const guessAndScore = this.questionRenderer.guessAndScore();
434
+ const guess = guessAndScore[0];
435
+ const score = guessAndScore[1];
436
+
437
+ // Continue to include an empty guess for the now defunct answer area.
438
+ // TODO(alex): Check whether we rely on the format here for
439
+ // analyzing ProblemLogs. If not, remove this layer.
440
+ const maxCompatGuess = [guess, []];
441
+
442
+ const keScore = Util.keScoreFromPerseusScore(
443
+ score,
444
+ maxCompatGuess,
445
+ this.questionRenderer.getSerializedState(),
446
+ );
447
+
448
+ const emptyQuestionAreaWidgets = this.questionRenderer.emptyWidgets();
449
+
450
+ this.setState({
451
+ questionCompleted: keScore.correct,
452
+ questionHighlightedWidgets: emptyQuestionAreaWidgets,
453
+ });
454
+
455
+ return keScore;
456
+ }
457
+
458
+ /**
459
+ * Returns an array of all widget IDs in the order they occur in
460
+ * the question content.
461
+ */
462
+ getWidgetIds(): ReadonlyArray<string> {
463
+ return this.questionRenderer.getWidgetIds();
464
+ }
465
+
466
+ /**
467
+ * Returns an object mapping from widget ID to KE-style score.
468
+ * The keys of this object are the values of the array returned
469
+ * from `getWidgetIds`.
470
+ */
471
+ scoreWidgets(): {
472
+ [id: string]: KEScore;
473
+ } {
474
+ const qScore = this.questionRenderer.scoreWidgets();
475
+ const qGuess = this.questionRenderer.getUserInputForWidgets();
476
+ const state = this.questionRenderer.getSerializedState();
477
+ // @ts-expect-error - TS2322 - Type 'Partial<Record<string, KEScore>>' is not assignable to type '{ [id: string]: KEScore; }'. | TS2345 - Argument of type '{ [widgetId: string]: PerseusScore; }' is not assignable to parameter of type 'Partial<Record<string, { type: "invalid"; message?: string | null | undefined; suppressAlmostThere?: boolean | null | undefined; }>>'.
478
+ return mapObject(qScore, (score, id) => {
479
+ return Util.keScoreFromPerseusScore(score, qGuess[id], state[id]);
480
+ });
481
+ }
482
+
483
+ /**
484
+ * Get a representation of the current state of the item.
485
+ */
486
+ getSerializedState(): SerializedState {
487
+ return {
488
+ question: this.questionRenderer.getSerializedState(),
489
+ hints: this.hintsRenderer?.getSerializedState(),
490
+ };
491
+ }
492
+
493
+ // TODO(kevinb): make the type for this state better
494
+ restoreSerializedState(state: SerializedState, callback?: () => void) {
495
+ // We need to wait for both the question renderer and the hints
496
+ // renderer to finish restoring their states.
497
+ let numCallbacks = 2;
498
+ const fireCallback = () => {
499
+ --numCallbacks;
500
+ if (callback && numCallbacks === 0) {
501
+ callback();
502
+ }
503
+ };
504
+
505
+ this.questionRenderer.restoreSerializedState(
506
+ state.question,
507
+ fireCallback,
508
+ );
509
+ this.hintsRenderer?.restoreSerializedState(state.hints, fireCallback);
510
+ }
511
+
512
+ showRationalesForCurrentlySelectedChoices() {
513
+ this.questionRenderer.showRationalesForCurrentlySelectedChoices();
514
+ }
515
+
516
+ deselectIncorrectSelectedChoices() {
517
+ this.questionRenderer.deselectIncorrectSelectedChoices();
518
+ }
519
+
520
+ render(): React.ReactNode {
521
+ return (
522
+ <DependenciesContext.Provider value={this.props.dependencies}>
523
+ <div />
524
+ </DependenciesContext.Provider>
525
+ );
526
+ }
527
+ }
528
+
529
+ export default ItemRenderer;