@khanacademy/perseus 12.3.0 → 12.4.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": "Core Perseus API (includes renderers and widgets)",
4
4
  "author": "Khan Academy",
5
5
  "license": "MIT",
6
- "version": "12.3.0",
6
+ "version": "12.4.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -22,13 +22,13 @@
22
22
  "test": "bash -c 'yarn --silent --cwd \"../..\" test ${@:0} $($([[ ${@: -1} = -* ]] || [[ ${@: -1} = bash ]]) && echo $PWD)'"
23
23
  },
24
24
  "dependencies": {
25
- "@khanacademy/kas": "^0.3.4",
26
- "@khanacademy/kmath": "^0.1.5",
27
- "@khanacademy/math-input": "^14.2.1",
28
- "@khanacademy/perseus-core": "1.2.0",
29
- "@khanacademy/perseus-linter": "^0.3.7",
30
- "@khanacademy/pure-markdown": "^0.2.9",
31
- "@khanacademy/simple-markdown": "^0.10.3"
25
+ "@khanacademy/kas": "^0.3.5",
26
+ "@khanacademy/kmath": "^0.1.6",
27
+ "@khanacademy/math-input": "^14.2.2",
28
+ "@khanacademy/perseus-core": "1.3.0",
29
+ "@khanacademy/perseus-linter": "^0.3.8",
30
+ "@khanacademy/pure-markdown": "^0.2.10",
31
+ "@khanacademy/simple-markdown": "^0.10.4"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@khanacademy/wonder-blocks-button": "^4.1.3",
@@ -0,0 +1,126 @@
1
+ import "@testing-library/jest-dom"; // Joyous side-effects
2
+ import {render, screen} from "@testing-library/react";
3
+ import * as React from "react";
4
+
5
+ import {testDependencies} from "../../../../testing/test-dependencies";
6
+ import * as Dependencies from "../dependencies";
7
+ import WidgetContainer from "../widget-container";
8
+ import {registerWidget} from "../widgets";
9
+ import PassageWidget from "../widgets/passage";
10
+
11
+ import type {WidgetExports} from "../types";
12
+
13
+ const MockWidgetComponent = ({
14
+ text,
15
+ fail = false,
16
+ }: {
17
+ text: string;
18
+ fail: boolean;
19
+ }) => {
20
+ if (fail) {
21
+ throw new Error("MockWidget failed to render");
22
+ }
23
+
24
+ return <div>{text}</div>;
25
+ };
26
+
27
+ const MockWidget: WidgetExports<typeof MockWidgetComponent> = {
28
+ name: "mock-widget",
29
+ displayName: "Mock Widget",
30
+ widget: MockWidgetComponent,
31
+ };
32
+
33
+ describe("widget-container", () => {
34
+ it("should render nothing when requested widget not registered", () => {
35
+ // Arrange
36
+ const warnMock = jest.spyOn(console, "warn").mockImplementation();
37
+
38
+ // Act
39
+ render(
40
+ <WidgetContainer
41
+ type="invalid-widget"
42
+ id="invalid-widget 1"
43
+ shouldHighlight={false}
44
+ initialProps={{apiOptions: {isMobile: false}}}
45
+ />,
46
+ );
47
+
48
+ // Assert
49
+ expect(warnMock).toHaveBeenCalledWith(
50
+ "Widget type 'invalid-widget' not found!",
51
+ );
52
+ });
53
+
54
+ it("should render the requested widget", async () => {
55
+ // Arrange
56
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
57
+ testDependencies,
58
+ );
59
+
60
+ registerWidget("passage", PassageWidget);
61
+
62
+ // Act
63
+ render(
64
+ <WidgetContainer
65
+ type="passage"
66
+ id="passage 1"
67
+ shouldHighlight={false}
68
+ initialProps={{
69
+ passageTitle: "Greeting",
70
+ passageText: "Hello world!",
71
+ footnotes: null,
72
+ showLineNumbers: true,
73
+
74
+ findWidgets: () => [],
75
+
76
+ apiOptions: {isMobile: false},
77
+ }}
78
+ />,
79
+ );
80
+
81
+ // Assert
82
+ expect(await screen.findByText("Greeting")).toBeInTheDocument();
83
+ expect(await screen.findByText("Hello world")).toBeInTheDocument();
84
+ });
85
+
86
+ it("should send analytics even when widget rendering errors", () => {
87
+ // Arrange
88
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
89
+ testDependencies,
90
+ );
91
+ jest.spyOn(console, "error").mockImplementation(() => {});
92
+
93
+ const onAnalyticsEventSpy = jest.fn();
94
+ const depsV2 = {analytics: {onAnalyticsEvent: onAnalyticsEventSpy}};
95
+
96
+ registerWidget("mock-widget", MockWidget);
97
+
98
+ // Act
99
+ render(
100
+ <Dependencies.DependenciesContext.Provider value={depsV2}>
101
+ <WidgetContainer
102
+ type="mock-widget"
103
+ id="mock-widget 1"
104
+ shouldHighlight={false}
105
+ initialProps={{
106
+ text: "Hello world!",
107
+ fail: true,
108
+
109
+ findWidgets: () => [],
110
+
111
+ apiOptions: {isMobile: false},
112
+ }}
113
+ />
114
+ </Dependencies.DependenciesContext.Provider>,
115
+ );
116
+
117
+ // Assert
118
+ expect(onAnalyticsEventSpy).toHaveBeenCalledWith({
119
+ type: "perseus:widget-rendering-error",
120
+ payload: {
121
+ widgetType: "mock-widget",
122
+ widgetId: "mock-widget 1",
123
+ },
124
+ });
125
+ });
126
+ });
@@ -21,6 +21,7 @@ class ErrorBoundary extends React.Component<Props, State> {
21
21
 
22
22
  componentDidCatch(error: Error, info: any) {
23
23
  this.setState({error: error.toString()});
24
+ this.props.onError?.(error, info);
24
25
  Log.error("Perseus error boundary caught error", Errors.Internal, {
25
26
  cause: error,
26
27
  loggedMetadata: {
@@ -498,7 +498,9 @@
498
498
  margin-bottom: 2 * @baseUnit;
499
499
  }
500
500
 
501
- .perseus-renderer > .paragraph ul:not(.perseus-widget-radio) {
501
+ .perseus-renderer
502
+ > .paragraph
503
+ ul:not(.perseus-widget-radio, .indicatorContainer) {
502
504
  margin: 0 0 0 1em;
503
505
  padding: 0;
504
506
 
@@ -133,7 +133,7 @@
133
133
  margin: -11px 0px 22px 0px; // first-level lists need padding
134
134
  }
135
135
 
136
- .paragraph ul:not(.perseus-widget-radio) {
136
+ .paragraph ul:not(.perseus-widget-radio, .indicatorContainer) {
137
137
  padding-left: 35px;
138
138
  list-style-type: disc;
139
139
  }
@@ -144,11 +144,12 @@ class WidgetContainer extends React.Component<Props, State> {
144
144
  widget_type: type,
145
145
  widget_id: this.props.id,
146
146
  }}
147
- onError={(error) => {
147
+ onError={() => {
148
148
  analytics.onAnalyticsEvent({
149
149
  type: "perseus:widget-rendering-error",
150
150
  payload: {
151
151
  widgetType: type,
152
+ widgetId: this.props.id,
152
153
  },
153
154
  });
154
155
  }}
@@ -38,32 +38,63 @@ exports[`graded group widget should snapshot 1`] = `
38
38
  class="spacer_bc4egv"
39
39
  />
40
40
  <ul
41
- class="indicatorContainer_rjg7c2"
41
+ class="indicatorContainer_2pd7o4 indicatorContainer"
42
42
  >
43
43
  <li
44
- aria-label="Skip to Problem 1a"
45
- class="indicator_qk5jgn-o_O-selectedIndicator_14jsc6v"
46
- role="button"
47
- tabindex="0"
44
+ class="indicator_7k5kza"
48
45
  >
49
- <span
50
- class="srOnly_19bpjuy"
46
+ <button
47
+ aria-disabled="false"
48
+ aria-label="Skip to Problem 1a"
49
+ class="button_vr44p2-o_O-reset_152ygtm-o_O-link_13xlah4-o_O-indicatorButton_8uaklu"
50
+ role="button"
51
+ type="button"
51
52
  >
52
- Current
53
- </span>
53
+ <div
54
+ class="default_xu2jcg-o_O-indicatorDot_yy8ygm"
55
+ >
56
+ <div
57
+ class="default_xu2jcg-o_O-indicatorDotActive_1pky1a"
58
+ >
59
+ <span
60
+ class="srOnly_19bpjuy"
61
+ >
62
+ Current
63
+ </span>
64
+ </div>
65
+ </div>
66
+ </button>
54
67
  </li>
55
68
  <li
56
- aria-label="Skip to Problem 1b"
57
- class="indicator_qk5jgn"
58
- role="button"
59
- tabindex="0"
60
- />
69
+ class="indicator_7k5kza"
70
+ >
71
+ <button
72
+ aria-disabled="false"
73
+ aria-label="Skip to Problem 1b"
74
+ class="button_vr44p2-o_O-reset_152ygtm-o_O-link_13xlah4-o_O-indicatorButton_8uaklu"
75
+ role="button"
76
+ type="button"
77
+ >
78
+ <div
79
+ class="default_xu2jcg-o_O-indicatorDot_yy8ygm"
80
+ />
81
+ </button>
82
+ </li>
61
83
  <li
62
- aria-label="Skip to Problem 1c"
63
- class="indicator_qk5jgn"
64
- role="button"
65
- tabindex="0"
66
- />
84
+ class="indicator_7k5kza"
85
+ >
86
+ <button
87
+ aria-disabled="false"
88
+ aria-label="Skip to Problem 1c"
89
+ class="button_vr44p2-o_O-reset_152ygtm-o_O-link_13xlah4-o_O-indicatorButton_8uaklu"
90
+ role="button"
91
+ type="button"
92
+ >
93
+ <div
94
+ class="default_xu2jcg-o_O-indicatorDot_yy8ygm"
95
+ />
96
+ </button>
97
+ </li>
67
98
  </ul>
68
99
  </div>
69
100
  <div
@@ -1,8 +1,11 @@
1
1
  /* eslint-disable @khanacademy/ts-no-error-suppressions */
2
2
  import {linterContextDefault} from "@khanacademy/perseus-linter";
3
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
3
4
  import Color from "@khanacademy/wonder-blocks-color";
5
+ import {View} from "@khanacademy/wonder-blocks-core";
4
6
  import * as i18n from "@khanacademy/wonder-blocks-i18n";
5
7
  import {StyleSheet, css} from "aphrodite";
8
+ import classNames from "classnames";
6
9
  import * as React from "react";
7
10
 
8
11
  import {getDependencies} from "../dependencies";
@@ -38,28 +41,43 @@ class Indicators extends React.Component<IndicatorsProps> {
38
41
 
39
42
  render(): React.ReactNode {
40
43
  return (
41
- <ul className={css(styles.indicatorContainer)}>
44
+ <ul
45
+ // reduntantly add class name for use in .less files--
46
+ // the styles object key gets hashed
47
+ className={classNames(
48
+ css(styles.indicatorContainer),
49
+ "indicatorContainer",
50
+ )}
51
+ >
42
52
  {this.props.gradedGroups.map(({title}, i) => (
43
- <li
44
- role="button"
45
- aria-label={i18n._("Skip to %(title)s", {
46
- title,
47
- })}
48
- key={title}
49
- className={css(
50
- styles.indicator,
51
- i === this.props.currentGroup &&
52
- styles.selectedIndicator,
53
- )}
54
- tabIndex={0}
55
- onClick={() => this.props.onChangeCurrentGroup(i)}
56
- onKeyDown={(e) => this.handleKeyDown(e, i)}
57
- >
58
- {i === this.props.currentGroup && (
59
- <span className={css(a11y.srOnly)}>
60
- {i18n._("Current")}
61
- </span>
62
- )}
53
+ <li className={css(styles.indicator)} key={title}>
54
+ <Clickable
55
+ role="button"
56
+ aria-label={i18n._("Skip to %(title)s", {
57
+ title,
58
+ })}
59
+ style={styles.indicatorButton}
60
+ onClick={() => this.props.onChangeCurrentGroup(i)}
61
+ onKeyDown={(e) => this.handleKeyDown(e, i)}
62
+ >
63
+ {({hovered, focused, pressed}) => (
64
+ <View
65
+ style={[
66
+ styles.indicatorDot,
67
+ (hovered || focused || pressed) &&
68
+ styles.indicatorDotFocused,
69
+ ]}
70
+ >
71
+ {i === this.props.currentGroup && (
72
+ <View style={styles.indicatorDotActive}>
73
+ <span className={css(a11y.srOnly)}>
74
+ {i18n._("Current")}
75
+ </span>
76
+ </View>
77
+ )}
78
+ </View>
79
+ )}
80
+ </Clickable>
63
81
  </li>
64
82
  ))}
65
83
  </ul>
@@ -262,20 +280,47 @@ const styles = StyleSheet.create({
262
280
  flexDirection: "row",
263
281
  listStyle: "none",
264
282
  margin: "unset",
283
+ paddingInlineStart: "unset",
284
+ flexWrap: "wrap",
265
285
  },
266
286
 
267
287
  indicator: {
288
+ width: 24,
289
+ height: 24,
290
+ },
291
+
292
+ indicatorButton: {
293
+ width: "100%",
294
+ height: "100%",
295
+ display: "flex",
296
+ flexWrap: "wrap",
297
+ placeContent: "center",
298
+ cursor: "pointer",
299
+
300
+ ":focus": {
301
+ outline: "none",
302
+ },
303
+ },
304
+
305
+ indicatorDot: {
306
+ boxSizing: "content-box",
268
307
  width: 10,
269
308
  height: 10,
270
309
  borderRadius: "100%",
271
- border: "3px solid",
310
+ borderWidth: 2,
272
311
  borderColor: Color.blue,
273
- marginLeft: 5,
274
- cursor: "pointer",
312
+ borderStyle: "solid",
313
+ },
314
+
315
+ indicatorDotFocused: {
316
+ borderWidth: 5,
317
+ borderStyle: "double",
275
318
  },
276
319
 
277
- selectedIndicator: {
320
+ indicatorDotActive: {
278
321
  backgroundColor: Color.blue,
322
+ width: "100%",
323
+ height: "100%",
279
324
  },
280
325
 
281
326
  container: {