@khanacademy/perseus 1.2.0 → 1.3.1
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 +19 -0
- package/dist/es/index.css +3 -3
- package/dist/es/index.js +145 -179
- package/dist/es/index.js.map +1 -1
- package/dist/index.css +3 -3
- package/dist/index.js +143 -177
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.js +1 -0
- package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +21 -11
- package/src/styles/widgets/passage.less +7 -3
- package/src/widgets/__stories__/passage-ref.stories.jsx +6 -2
- package/src/widgets/__stories__/passage.stories.jsx +9 -1
- package/src/widgets/__stories__/radio.stories.jsx +11 -0
- package/src/widgets/__testdata__/passage-ref_testdata.js +62 -0
- package/src/widgets/__testdata__/passage_testdata.js +25 -0
- package/src/widgets/__testdata__/radio_testdata.js +52 -0
- package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +21 -11
- package/src/widgets/__tests__/__snapshots__/passage-ref_test.jsx.snap +18 -18
- package/src/widgets/__tests__/__snapshots__/passage_test.jsx.snap +36 -36
- package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +221 -117
- package/src/widgets/__tests__/passage_test.jsx +6 -8
- package/src/widgets/__tests__/radio_test.jsx +3 -1
- package/src/widgets/passage.jsx +12 -41
- package/src/widgets/radio/__stories__/sat-styles.stories.jsx +191 -0
- package/src/widgets/radio/base-radio.jsx +1 -2
- package/src/widgets/radio/choice.jsx +4 -10
- package/src/widgets/radio/widget.jsx +128 -129
- package/src/widgets/passage/get-line-height-for-node.js +0 -9
|
@@ -8,15 +8,12 @@ import {testDependencies} from "../../../../../testing/test-dependencies.js";
|
|
|
8
8
|
import * as Dependencies from "../../dependencies.js";
|
|
9
9
|
import {ApiOptions} from "../../perseus-api.jsx";
|
|
10
10
|
import {question1, question2} from "../__testdata__/passage_testdata.js";
|
|
11
|
-
import PassageWidgetExport from "../passage.jsx";
|
|
12
|
-
import * as getLineHeightModule from "../passage/get-line-height-for-node.js";
|
|
11
|
+
import PassageWidgetExport, {LineHeightMeasurer} from "../passage.jsx";
|
|
13
12
|
|
|
14
13
|
import {renderQuestion} from "./renderQuestion.jsx";
|
|
15
14
|
|
|
16
15
|
import type {APIOptions} from "../../types.js";
|
|
17
16
|
|
|
18
|
-
jest.mock("../passage/get-line-height-for-node.js");
|
|
19
|
-
|
|
20
17
|
function renderPassage(overwrite) {
|
|
21
18
|
const widgetPropsBase = {
|
|
22
19
|
footnotes: "",
|
|
@@ -53,10 +50,6 @@ function renderPassage(overwrite) {
|
|
|
53
50
|
|
|
54
51
|
describe("passage widget", () => {
|
|
55
52
|
beforeEach(() => {
|
|
56
|
-
jest.spyOn(getLineHeightModule, "getLineHeightForNode").mockReturnValue(
|
|
57
|
-
21,
|
|
58
|
-
);
|
|
59
|
-
|
|
60
53
|
// passage widget uses the height of the entire rendered passage along
|
|
61
54
|
// with the above line height calculations to determine the number of
|
|
62
55
|
// lines rendered (as a way to provide line numbering).
|
|
@@ -66,6 +59,11 @@ describe("passage widget", () => {
|
|
|
66
59
|
"get",
|
|
67
60
|
).mockReturnValue(200.0);
|
|
68
61
|
|
|
62
|
+
jest.spyOn(
|
|
63
|
+
LineHeightMeasurer.prototype,
|
|
64
|
+
"measureLineHeight",
|
|
65
|
+
).mockReturnValue(20);
|
|
66
|
+
|
|
69
67
|
jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
|
|
70
68
|
testDependencies,
|
|
71
69
|
);
|
|
@@ -800,7 +800,9 @@ describe("multi-choice question", () => {
|
|
|
800
800
|
|
|
801
801
|
// Act
|
|
802
802
|
const option = screen.getAllByRole("checkbox");
|
|
803
|
-
userEvent.click(option[
|
|
803
|
+
userEvent.click(option[0]); // correct
|
|
804
|
+
userEvent.click(option[1]); // correct
|
|
805
|
+
userEvent.click(option[2]); // incorrect
|
|
804
806
|
|
|
805
807
|
// Assert
|
|
806
808
|
expect(renderer).toHaveInvalidInput(
|
package/src/widgets/passage.jsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// @flow
|
|
2
|
-
|
|
3
2
|
import {linterContextDefault} from "@khanacademy/perseus-linter";
|
|
4
3
|
import * as i18n from "@khanacademy/wonder-blocks-i18n";
|
|
5
4
|
import {StyleSheet, css} from "aphrodite";
|
|
@@ -12,7 +11,6 @@ import HighlightableContent from "../components/highlighting/highlightable-conte
|
|
|
12
11
|
import {getDependencies} from "../dependencies.js";
|
|
13
12
|
import Renderer from "../renderer.jsx";
|
|
14
13
|
|
|
15
|
-
import {getLineHeightForNode} from "./passage/get-line-height-for-node.js";
|
|
16
14
|
import PassageMarkdown from "./passage/passage-markdown.jsx";
|
|
17
15
|
|
|
18
16
|
import type {SerializedHighlightSet} from "../components/highlighting/types.js";
|
|
@@ -27,51 +25,24 @@ import type {
|
|
|
27
25
|
import type {ParseState} from "./passage/passage-markdown.jsx";
|
|
28
26
|
import type {SingleASTNode} from "@khanacademy/simple-markdown";
|
|
29
27
|
|
|
30
|
-
// A fake paragraph to measure the line height of the passage
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class LineHeightMeasurer extends React.Component<{...}> {
|
|
35
|
-
_cachedLineHeight: number;
|
|
36
|
-
body: ?HTMLDivElement;
|
|
37
|
-
end: ?HTMLDivElement;
|
|
28
|
+
// A fake paragraph to measure the line height of the passage,
|
|
29
|
+
// so we can adapt to browser zoom
|
|
30
|
+
export class LineHeightMeasurer extends React.Component<{...}> {
|
|
31
|
+
_line: ?HTMLDivElement;
|
|
38
32
|
|
|
39
33
|
measureLineHeight(): number {
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return this._cachedLineHeight;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
forceMeasureLineHeight() {
|
|
48
|
-
const body = this.body;
|
|
49
|
-
const end = this.end;
|
|
50
|
-
|
|
51
|
-
if (!body || !end) {
|
|
52
|
-
return;
|
|
34
|
+
if (!this._line) {
|
|
35
|
+
return 0;
|
|
53
36
|
}
|
|
54
37
|
|
|
55
|
-
|
|
56
|
-
body.textContent = " \u0080";
|
|
57
|
-
|
|
58
|
-
// Now, the line height is the difference between the top of the
|
|
59
|
-
// second line and the top of the first line.
|
|
60
|
-
this._cachedLineHeight = getLineHeightForNode(body, end);
|
|
61
|
-
|
|
62
|
-
// Clear out the first line so it doesn't overlap the passage.
|
|
63
|
-
body.textContent = "";
|
|
38
|
+
return this._line.clientHeight;
|
|
64
39
|
}
|
|
65
40
|
|
|
66
41
|
render(): React.Node {
|
|
67
42
|
return (
|
|
68
|
-
<div className={css(styles.measurer)}>
|
|
69
|
-
<div>
|
|
70
|
-
|
|
71
|
-
ref={(ref) => (this.body = ref)}
|
|
72
|
-
className="paragraph"
|
|
73
|
-
/>
|
|
74
|
-
<div ref={(ref) => (this.end = ref)} />
|
|
43
|
+
<div className={css(styles.measurer)} aria-hidden="true">
|
|
44
|
+
<div ref={(ref) => (this._line = ref)} className="paragraph">
|
|
45
|
+
Line Height Measurement
|
|
75
46
|
</div>
|
|
76
47
|
</div>
|
|
77
48
|
);
|
|
@@ -81,9 +52,9 @@ class LineHeightMeasurer extends React.Component<{...}> {
|
|
|
81
52
|
const styles = StyleSheet.create({
|
|
82
53
|
measurer: {
|
|
83
54
|
position: "absolute",
|
|
84
|
-
width: "100%",
|
|
85
55
|
top: 0,
|
|
86
56
|
left: 0,
|
|
57
|
+
visibility: "hidden",
|
|
87
58
|
// keep from blocking text selection
|
|
88
59
|
zIndex: -1,
|
|
89
60
|
},
|
|
@@ -168,7 +139,7 @@ class Passage extends React.Component<PassageProps, PassageState> {
|
|
|
168
139
|
// height changes we expect are subpixel changes when the user
|
|
169
140
|
// zooms in/out, and the only way to listen for zoom events is to
|
|
170
141
|
// listen for resize events.
|
|
171
|
-
this._lineHeightMeasurerRef?.
|
|
142
|
+
this._lineHeightMeasurerRef?.measureLineHeight();
|
|
172
143
|
this._updateState();
|
|
173
144
|
}, 500);
|
|
174
145
|
window.addEventListener("resize", this._onResize);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {action} from "@storybook/addon-actions";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import {generateChoice} from "../../__testdata__/base-radio_testdata.js";
|
|
6
|
+
import BaseRadio from "../base-radio.jsx";
|
|
7
|
+
|
|
8
|
+
type StoryArgs = {|
|
|
9
|
+
multipleSelect: boolean,
|
|
10
|
+
editMode: boolean,
|
|
11
|
+
countChoices: boolean,
|
|
12
|
+
|};
|
|
13
|
+
|
|
14
|
+
type Story = {|
|
|
15
|
+
title: string,
|
|
16
|
+
args: StoryArgs,
|
|
17
|
+
|};
|
|
18
|
+
|
|
19
|
+
export default ({
|
|
20
|
+
title: "Perseus/Widgets/Radio/SAT Base Radio",
|
|
21
|
+
args: {
|
|
22
|
+
multipleSelect: false,
|
|
23
|
+
editMode: false,
|
|
24
|
+
countChoices: false,
|
|
25
|
+
},
|
|
26
|
+
}: Story);
|
|
27
|
+
|
|
28
|
+
const defaultProps = {
|
|
29
|
+
apiOptions: {
|
|
30
|
+
satStyling: true,
|
|
31
|
+
},
|
|
32
|
+
choices: [
|
|
33
|
+
generateChoice({
|
|
34
|
+
content: "Content 1",
|
|
35
|
+
}),
|
|
36
|
+
generateChoice({
|
|
37
|
+
content: "Content 2",
|
|
38
|
+
}),
|
|
39
|
+
generateChoice({
|
|
40
|
+
content: "Content 3",
|
|
41
|
+
correct: true,
|
|
42
|
+
}),
|
|
43
|
+
generateChoice({
|
|
44
|
+
isNoneOfTheAbove: true,
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
deselectEnabled: false,
|
|
48
|
+
editMode: false,
|
|
49
|
+
labelWrap: false,
|
|
50
|
+
countChoices: false,
|
|
51
|
+
numCorrect: 1,
|
|
52
|
+
multipleSelect: false,
|
|
53
|
+
|
|
54
|
+
// A callback indicating that this choice has changed. Its argument is
|
|
55
|
+
// an object with two keys: `checked` and `crossedOut`. Each contains
|
|
56
|
+
// an array of boolean values, specifying the new checked and
|
|
57
|
+
// crossed-out value of each choice.
|
|
58
|
+
onChange: action("changed"),
|
|
59
|
+
|
|
60
|
+
// Whether this widget was the most recently used widget in this
|
|
61
|
+
// Renderer. Determines whether we'll auto-scroll the page upon
|
|
62
|
+
// entering review mode.
|
|
63
|
+
isLastUsedWidget: false,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const Interactive = (args: StoryArgs): React.Node => {
|
|
67
|
+
const overwrittenProps = {...defaultProps, ...args};
|
|
68
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const SingleSelectWithNothingSelected = (
|
|
72
|
+
args: StoryArgs,
|
|
73
|
+
): React.Node => {
|
|
74
|
+
const overwrittenProps = {...defaultProps, multipleSelect: false};
|
|
75
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const MultipleSelectWithNothingSelected = (
|
|
79
|
+
args: StoryArgs,
|
|
80
|
+
): React.Node => {
|
|
81
|
+
const overwrittenProps = {...defaultProps, multipleSelect: true};
|
|
82
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const MultipleSelectWithCountChoicesLabel = (
|
|
86
|
+
args: StoryArgs,
|
|
87
|
+
): React.Node => {
|
|
88
|
+
const overwrittenProps = {
|
|
89
|
+
...defaultProps,
|
|
90
|
+
multipleSelect: true,
|
|
91
|
+
numCorrect: 2,
|
|
92
|
+
countChoices: true,
|
|
93
|
+
};
|
|
94
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const SingleSelected = (args: StoryArgs): React.Node => {
|
|
98
|
+
const choices = Array(4)
|
|
99
|
+
.fill(null)
|
|
100
|
+
.map((_, i) => generateChoice({content: `Choice ${i + 1}`}));
|
|
101
|
+
choices[1].checked = true;
|
|
102
|
+
|
|
103
|
+
const overwrittenProps = {...defaultProps, multipleSelect: false, choices};
|
|
104
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const MultipleSelected = (args: StoryArgs): React.Node => {
|
|
108
|
+
const choices = Array(4)
|
|
109
|
+
.fill(null)
|
|
110
|
+
.map((_, i) => generateChoice({content: `Choice ${i + 1}`}));
|
|
111
|
+
choices[1].checked = true;
|
|
112
|
+
choices[2].checked = true;
|
|
113
|
+
|
|
114
|
+
const overwrittenProps = {...defaultProps, multipleSelect: true, choices};
|
|
115
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const SingleKitchenSink = (args: StoryArgs): React.Node => {
|
|
119
|
+
const choices = Array(4)
|
|
120
|
+
.fill(null)
|
|
121
|
+
.map((_, i) => {
|
|
122
|
+
const choice = generateChoice({
|
|
123
|
+
content: `Choice ${i + 1}`,
|
|
124
|
+
rationale: "This is a neat rationale",
|
|
125
|
+
hasRationale: true,
|
|
126
|
+
showRationale: true,
|
|
127
|
+
correct: false,
|
|
128
|
+
showCorrectness: true,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return choice;
|
|
132
|
+
});
|
|
133
|
+
choices[1].checked = true;
|
|
134
|
+
choices[2].correct = true;
|
|
135
|
+
|
|
136
|
+
const rubricChoices = choices.map(({correct}) => ({
|
|
137
|
+
// note(matthew): reviewModeRubric.choices requires content,
|
|
138
|
+
// but I don't see how it's getting used and Flow gets mad
|
|
139
|
+
// when I use choice.content because it's not a string.
|
|
140
|
+
// reviewModeRubric could probably use a look over.
|
|
141
|
+
content: "",
|
|
142
|
+
correct,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const overwrittenProps = {
|
|
146
|
+
...defaultProps,
|
|
147
|
+
multipleSelect: false,
|
|
148
|
+
reviewModeRubric: {choices: rubricChoices},
|
|
149
|
+
choices,
|
|
150
|
+
};
|
|
151
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const MultipleKitchenSink = (args: StoryArgs): React.Node => {
|
|
155
|
+
const choices = Array(4)
|
|
156
|
+
.fill(null)
|
|
157
|
+
.map((_, i) => {
|
|
158
|
+
const choice = generateChoice({
|
|
159
|
+
content: `Choice ${i + 1}`,
|
|
160
|
+
rationale: "This is a neat rationale",
|
|
161
|
+
hasRationale: true,
|
|
162
|
+
showRationale: true,
|
|
163
|
+
correct: false,
|
|
164
|
+
showCorrectness: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return choice;
|
|
168
|
+
});
|
|
169
|
+
choices[1].checked = true;
|
|
170
|
+
choices[2].checked = true;
|
|
171
|
+
choices[2].correct = true;
|
|
172
|
+
choices[3].correct = true;
|
|
173
|
+
|
|
174
|
+
const rubricChoices = choices.map((c) => ({
|
|
175
|
+
// note(matthew): reviewModeRubric.choices requires content,
|
|
176
|
+
// but I don't see how it's getting used and Flow gets mad
|
|
177
|
+
// when I use choice.content because it's not a string.
|
|
178
|
+
// reviewModeRubric could probably use a look over.
|
|
179
|
+
content: "",
|
|
180
|
+
correct: c.correct,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
const overwrittenProps = {
|
|
184
|
+
...defaultProps,
|
|
185
|
+
multipleSelect: true,
|
|
186
|
+
numCorrect: 2,
|
|
187
|
+
reviewModeRubric: {choices: rubricChoices},
|
|
188
|
+
choices,
|
|
189
|
+
};
|
|
190
|
+
return <BaseRadio {...overwrittenProps} />;
|
|
191
|
+
};
|
|
@@ -14,7 +14,7 @@ import {useState, useEffect} from "react";
|
|
|
14
14
|
import _ from "underscore";
|
|
15
15
|
|
|
16
16
|
import Icon from "../../components/icon.jsx";
|
|
17
|
-
import {ApiOptions} from "../../perseus-api.jsx";
|
|
17
|
+
import {ApiOptions, ClassNames} from "../../perseus-api.jsx";
|
|
18
18
|
import * as styleConstants from "../../styles/constants.js";
|
|
19
19
|
import mediaQueries from "../../styles/media-queries.js";
|
|
20
20
|
|
|
@@ -140,7 +140,6 @@ function Choice(props: ChoicePropsWithForwardRef): React.Node {
|
|
|
140
140
|
css(
|
|
141
141
|
!apiOptions.satStyling && styles.description,
|
|
142
142
|
apiOptions.satStyling && styles.satDescription,
|
|
143
|
-
satCorrectChoice && styles.satDescriptionCorrect,
|
|
144
143
|
satCorrectChoice && checked && styles.satDescriptionCorrectChecked,
|
|
145
144
|
satIncorrectChecked && styles.satDescriptionIncorrectChecked,
|
|
146
145
|
),
|
|
@@ -179,12 +178,12 @@ function Choice(props: ChoicePropsWithForwardRef): React.Node {
|
|
|
179
178
|
dispay: "flex",
|
|
180
179
|
flexDirection: "column",
|
|
181
180
|
}}
|
|
181
|
+
className={descriptionClassName}
|
|
182
182
|
>
|
|
183
183
|
<div
|
|
184
184
|
style={{
|
|
185
185
|
display: "flex",
|
|
186
186
|
flexDirection: "row",
|
|
187
|
-
width: "100%",
|
|
188
187
|
opacity: showDimmed ? 0.5 : 1.0,
|
|
189
188
|
}}
|
|
190
189
|
>
|
|
@@ -208,6 +207,7 @@ function Choice(props: ChoicePropsWithForwardRef): React.Node {
|
|
|
208
207
|
apiOptions.readOnly
|
|
209
208
|
}
|
|
210
209
|
tabIndex={-1}
|
|
210
|
+
className={ClassNames.RADIO.OPTION_CONTENT}
|
|
211
211
|
/>
|
|
212
212
|
<label htmlFor={choiceId}>
|
|
213
213
|
{a11yText} {content}
|
|
@@ -222,7 +222,6 @@ function Choice(props: ChoicePropsWithForwardRef): React.Node {
|
|
|
222
222
|
crossedOut: false,
|
|
223
223
|
});
|
|
224
224
|
}}
|
|
225
|
-
className={descriptionClassName}
|
|
226
225
|
disabled={
|
|
227
226
|
disabled ||
|
|
228
227
|
apiOptions.staticRender ||
|
|
@@ -390,11 +389,7 @@ const styles = StyleSheet.create({
|
|
|
390
389
|
boxSizing: "border-box",
|
|
391
390
|
cursor: "pointer",
|
|
392
391
|
marginLeft: 0,
|
|
393
|
-
padding: "17px 14px",
|
|
394
|
-
},
|
|
395
|
-
|
|
396
|
-
satDescriptionCorrect: {
|
|
397
|
-
color: styleConstants.satCorrectColor,
|
|
392
|
+
padding: "17px 14px 17px 0",
|
|
398
393
|
},
|
|
399
394
|
|
|
400
395
|
satDescriptionCorrectChecked: {
|
|
@@ -402,7 +397,6 @@ const styles = StyleSheet.create({
|
|
|
402
397
|
},
|
|
403
398
|
|
|
404
399
|
satDescriptionIncorrectChecked: {
|
|
405
|
-
color: styleConstants.satIncorrectColor,
|
|
406
400
|
backgroundColor: styleConstants.satIncorrectBackgroundColor,
|
|
407
401
|
},
|
|
408
402
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable react/sort-comp */
|
|
2
1
|
// @flow
|
|
3
2
|
import {linterContextDefault} from "@khanacademy/perseus-linter";
|
|
4
3
|
import * as i18n from "@khanacademy/wonder-blocks-i18n";
|
|
@@ -66,6 +65,134 @@ class Radio extends React.Component<Props> {
|
|
|
66
65
|
linterContext: linterContextDefault,
|
|
67
66
|
};
|
|
68
67
|
|
|
68
|
+
static validate(userInput: UserInput, rubric: Rubric): PerseusScore {
|
|
69
|
+
const numSelected = userInput.choicesSelected.reduce(
|
|
70
|
+
(sum, selected) => {
|
|
71
|
+
return sum + (selected ? 1 : 0);
|
|
72
|
+
},
|
|
73
|
+
0,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (numSelected === 0) {
|
|
77
|
+
return {
|
|
78
|
+
type: "invalid",
|
|
79
|
+
message: null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
userInput.numCorrect &&
|
|
85
|
+
userInput.numCorrect > 1 &&
|
|
86
|
+
numSelected !== userInput.numCorrect
|
|
87
|
+
) {
|
|
88
|
+
return {
|
|
89
|
+
type: "invalid",
|
|
90
|
+
message: i18n._("Please choose the correct number of answers."),
|
|
91
|
+
};
|
|
92
|
+
// If NOTA and some other answer are checked, ...
|
|
93
|
+
}
|
|
94
|
+
if (userInput.noneOfTheAboveSelected && numSelected > 1) {
|
|
95
|
+
return {
|
|
96
|
+
type: "invalid",
|
|
97
|
+
message: i18n._(
|
|
98
|
+
"'None of the above' may not be selected " +
|
|
99
|
+
"when other answers are selected.",
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const correct = userInput.choicesSelected.every((selected, i) => {
|
|
105
|
+
let isCorrect;
|
|
106
|
+
if (userInput.noneOfTheAboveIndex === i) {
|
|
107
|
+
isCorrect = rubric.choices.every((choice, j) => {
|
|
108
|
+
return i === j || !choice.correct;
|
|
109
|
+
});
|
|
110
|
+
} else {
|
|
111
|
+
isCorrect = !!rubric.choices[i].correct;
|
|
112
|
+
}
|
|
113
|
+
return isCorrect === selected;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
type: "points",
|
|
118
|
+
earned: correct ? 1 : 0,
|
|
119
|
+
total: 1,
|
|
120
|
+
message: null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static getUserInputFromProps(props: Props): UserInput {
|
|
125
|
+
// Return checked inputs in the form {choicesSelected: [bool]}. (Dear
|
|
126
|
+
// future timeline implementers: this used to be {value: i} before
|
|
127
|
+
// multiple select was added)
|
|
128
|
+
if (props.choiceStates) {
|
|
129
|
+
let noneOfTheAboveIndex = null;
|
|
130
|
+
let noneOfTheAboveSelected = false;
|
|
131
|
+
|
|
132
|
+
const choiceStates = props.choiceStates;
|
|
133
|
+
const choicesSelected = choiceStates.map(() => false);
|
|
134
|
+
const countChoices = props.countChoices;
|
|
135
|
+
const numCorrect = props.numCorrect;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < choicesSelected.length; i++) {
|
|
138
|
+
const index = props.choices[i].originalIndex;
|
|
139
|
+
|
|
140
|
+
choicesSelected[index] = choiceStates[i].selected;
|
|
141
|
+
|
|
142
|
+
if (props.choices[i].isNoneOfTheAbove) {
|
|
143
|
+
noneOfTheAboveIndex = index;
|
|
144
|
+
|
|
145
|
+
if (choicesSelected[i]) {
|
|
146
|
+
noneOfTheAboveSelected = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
countChoices,
|
|
153
|
+
choicesSelected,
|
|
154
|
+
numCorrect,
|
|
155
|
+
noneOfTheAboveIndex,
|
|
156
|
+
noneOfTheAboveSelected,
|
|
157
|
+
};
|
|
158
|
+
// Support legacy choiceState implementation
|
|
159
|
+
}
|
|
160
|
+
/* istanbul ignore if - props.values is deprecated */
|
|
161
|
+
const {values} = props;
|
|
162
|
+
if (values) {
|
|
163
|
+
let noneOfTheAboveIndex = null;
|
|
164
|
+
let noneOfTheAboveSelected = false;
|
|
165
|
+
|
|
166
|
+
const choicesSelected = [...values];
|
|
167
|
+
const countChoices = props.countChoices;
|
|
168
|
+
const numCorrect = props.numCorrect;
|
|
169
|
+
const valuesLength = values.length;
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < valuesLength; i++) {
|
|
172
|
+
const index = props.choices[i].originalIndex;
|
|
173
|
+
choicesSelected[index] = values[i];
|
|
174
|
+
|
|
175
|
+
if (props.choices[i].isNoneOfTheAbove) {
|
|
176
|
+
noneOfTheAboveIndex = index;
|
|
177
|
+
if (choicesSelected[i]) {
|
|
178
|
+
noneOfTheAboveSelected = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
choicesSelected,
|
|
184
|
+
noneOfTheAboveIndex,
|
|
185
|
+
noneOfTheAboveSelected,
|
|
186
|
+
countChoices,
|
|
187
|
+
numCorrect,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Nothing checked
|
|
191
|
+
return {
|
|
192
|
+
choicesSelected: props.choices.map(() => false),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
69
196
|
_renderRenderer: (content?: string) => React.Node = (
|
|
70
197
|
content?: string = "",
|
|
71
198
|
) => {
|
|
@@ -382,134 +509,6 @@ class Radio extends React.Component<Props> {
|
|
|
382
509
|
/>
|
|
383
510
|
);
|
|
384
511
|
}
|
|
385
|
-
|
|
386
|
-
static validate(userInput: UserInput, rubric: Rubric): PerseusScore {
|
|
387
|
-
const numSelected = userInput.choicesSelected.reduce(
|
|
388
|
-
(sum, selected) => {
|
|
389
|
-
return sum + (selected ? 1 : 0);
|
|
390
|
-
},
|
|
391
|
-
0,
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
if (numSelected === 0) {
|
|
395
|
-
return {
|
|
396
|
-
type: "invalid",
|
|
397
|
-
message: null,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (
|
|
402
|
-
userInput.numCorrect &&
|
|
403
|
-
userInput.numCorrect > 1 &&
|
|
404
|
-
numSelected !== userInput.numCorrect
|
|
405
|
-
) {
|
|
406
|
-
return {
|
|
407
|
-
type: "invalid",
|
|
408
|
-
message: i18n._("Please choose the correct number of answers."),
|
|
409
|
-
};
|
|
410
|
-
// If NOTA and some other answer are checked, ...
|
|
411
|
-
}
|
|
412
|
-
if (userInput.noneOfTheAboveSelected && numSelected > 1) {
|
|
413
|
-
return {
|
|
414
|
-
type: "invalid",
|
|
415
|
-
message: i18n._(
|
|
416
|
-
"'None of the above' may not be selected " +
|
|
417
|
-
"when other answers are selected.",
|
|
418
|
-
),
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const correct = userInput.choicesSelected.every((selected, i) => {
|
|
423
|
-
let isCorrect;
|
|
424
|
-
if (userInput.noneOfTheAboveIndex === i) {
|
|
425
|
-
isCorrect = rubric.choices.every((choice, j) => {
|
|
426
|
-
return i === j || !choice.correct;
|
|
427
|
-
});
|
|
428
|
-
} else {
|
|
429
|
-
isCorrect = !!rubric.choices[i].correct;
|
|
430
|
-
}
|
|
431
|
-
return isCorrect === selected;
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
return {
|
|
435
|
-
type: "points",
|
|
436
|
-
earned: correct ? 1 : 0,
|
|
437
|
-
total: 1,
|
|
438
|
-
message: null,
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
static getUserInputFromProps(props: Props): UserInput {
|
|
443
|
-
// Return checked inputs in the form {choicesSelected: [bool]}. (Dear
|
|
444
|
-
// future timeline implementers: this used to be {value: i} before
|
|
445
|
-
// multiple select was added)
|
|
446
|
-
if (props.choiceStates) {
|
|
447
|
-
let noneOfTheAboveIndex = null;
|
|
448
|
-
let noneOfTheAboveSelected = false;
|
|
449
|
-
|
|
450
|
-
const choiceStates = props.choiceStates;
|
|
451
|
-
const choicesSelected = choiceStates.map(() => false);
|
|
452
|
-
const countChoices = props.countChoices;
|
|
453
|
-
const numCorrect = props.numCorrect;
|
|
454
|
-
|
|
455
|
-
for (let i = 0; i < choicesSelected.length; i++) {
|
|
456
|
-
const index = props.choices[i].originalIndex;
|
|
457
|
-
|
|
458
|
-
choicesSelected[index] = choiceStates[i].selected;
|
|
459
|
-
|
|
460
|
-
if (props.choices[i].isNoneOfTheAbove) {
|
|
461
|
-
noneOfTheAboveIndex = index;
|
|
462
|
-
|
|
463
|
-
if (choicesSelected[i]) {
|
|
464
|
-
noneOfTheAboveSelected = true;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
return {
|
|
470
|
-
countChoices,
|
|
471
|
-
choicesSelected,
|
|
472
|
-
numCorrect,
|
|
473
|
-
noneOfTheAboveIndex,
|
|
474
|
-
noneOfTheAboveSelected,
|
|
475
|
-
};
|
|
476
|
-
// Support legacy choiceState implementation
|
|
477
|
-
}
|
|
478
|
-
/* istanbul ignore if - props.values is deprecated */
|
|
479
|
-
const {values} = props;
|
|
480
|
-
if (values) {
|
|
481
|
-
let noneOfTheAboveIndex = null;
|
|
482
|
-
let noneOfTheAboveSelected = false;
|
|
483
|
-
|
|
484
|
-
const choicesSelected = [...values];
|
|
485
|
-
const countChoices = props.countChoices;
|
|
486
|
-
const numCorrect = props.numCorrect;
|
|
487
|
-
const valuesLength = values.length;
|
|
488
|
-
|
|
489
|
-
for (let i = 0; i < valuesLength; i++) {
|
|
490
|
-
const index = props.choices[i].originalIndex;
|
|
491
|
-
choicesSelected[index] = values[i];
|
|
492
|
-
|
|
493
|
-
if (props.choices[i].isNoneOfTheAbove) {
|
|
494
|
-
noneOfTheAboveIndex = index;
|
|
495
|
-
if (choicesSelected[i]) {
|
|
496
|
-
noneOfTheAboveSelected = true;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
return {
|
|
501
|
-
choicesSelected,
|
|
502
|
-
noneOfTheAboveIndex,
|
|
503
|
-
noneOfTheAboveSelected,
|
|
504
|
-
countChoices,
|
|
505
|
-
numCorrect,
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
// Nothing checked
|
|
509
|
-
return {
|
|
510
|
-
choicesSelected: props.choices.map(() => false),
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
512
|
}
|
|
514
513
|
|
|
515
514
|
export default Radio;
|