Skip to content

Commit 443313d

Browse files
authored
Get the interactive graph flipbook ready for QE testing (#1100)
QE has a lot on their plate right now, so we need to use their time efficiently. This PR makes the Interactive Graph flipbook available via Storybook, so QE testers will be able to access it at https://khan.github.io/perseus. I've also made some quality-of-life improvements to the flipbook to make testing easier. The main one being that you can now see the index of the question you're viewing, and jump to any question by index. Issue: none ## Test plan: - `yarn storybook` - `open http://localhost:6006` - Search for "Flipbook" - You should be able to paste JSON from the WIP [test plan](https://khanacademy.atlassian.net/wiki/spaces/LC/pages/2738520299/Test+plan+for+segment+graph) into the box and flip through the questions. <img width="1008" alt="Screen Shot 2024-03-20 at 9 46 52 AM" src="https://github.com/Khan/perseus/assets/693920/9bcb1502-8ed6-4be8-862c-5186bab08824"> Author: benchristel Reviewers: jeremywiebe, nedredmond Required Reviewers: Approved By: jeremywiebe Checks: ✅ codecov/project, ✅ codecov/patch, ✅ Upload Coverage, ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Extract i18n strings (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Jest Coverage (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ gerald, ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x) Pull Request URL: #1100
1 parent 83244b3 commit 443313d

File tree

5 files changed

+232
-22
lines changed

5 files changed

+232
-22
lines changed

dev/editable-controlled-input.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as React from "react";
2+
import {useState} from "react";
3+
4+
import type {PropsFor} from "@khanacademy/wonder-blocks-core";
5+
6+
type Props = Omit<PropsFor<"input">, "value" | "onInput"> & {
7+
value: string;
8+
onInput: (newValue: string) => unknown;
9+
};
10+
11+
// The term "controlled input", in React, refers to an input element or
12+
// component whose displayed value is determined by its props, rather than by
13+
// the input's internal state. An EditableControlledInput is controlled as long
14+
// as it does not have focus. While it is focused, it becomes editable and emits
15+
// onInput events.
16+
export function EditableControlledInput(props: Props): React.ReactElement {
17+
const {value, onInput, ...restOfProps} = props;
18+
const [focused, setFocused] = useState(false);
19+
const [wipValue, setWipValue] = useState("");
20+
return (
21+
<input
22+
{...restOfProps}
23+
value={focused ? wipValue : value}
24+
onChange={(e) => {
25+
setWipValue(e.target.value);
26+
onInput(e.target.value);
27+
}}
28+
onFocus={() => {
29+
setWipValue(value);
30+
setFocused(true);
31+
}}
32+
onBlur={() => {
33+
setFocused(false);
34+
}}
35+
/>
36+
);
37+
}

dev/flipbook-model.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
previous,
66
setQuestions,
77
selectQuestions,
8+
removeCurrentQuestion,
9+
jumpToQuestion,
810
} from "./flipbook-model";
911

1012
import type {FlipbookModel} from "./flipbook-model";
@@ -20,7 +22,9 @@ describe("flipbookModelReducer", () => {
2022
requestedIndex: 42,
2123
});
2224
});
25+
});
2326

27+
describe("next/previous", () => {
2428
it("goes to the next question", () => {
2529
const model: FlipbookModel = {
2630
questions: `{}\n{}`,
@@ -75,7 +79,45 @@ describe("flipbookModelReducer", () => {
7579
requestedIndex: 0,
7680
});
7781
});
82+
});
83+
84+
describe("jumpToQuestion", () => {
85+
it("does nothing given a non-numeric value", () => {
86+
const state: FlipbookModel = {questions: "foo", requestedIndex: 0};
87+
const action = jumpToQuestion("blah");
88+
89+
expect(flipbookModelReducer(state, action)).toEqual({
90+
questions: "foo",
91+
requestedIndex: 0,
92+
});
93+
});
94+
95+
it("does nothing given a negative value", () => {});
96+
97+
it("does nothing given zero", () => {
98+
// jumpToQuestion accepts raw user input, which uses 1-based indexing,
99+
// not 0-based. So 0 is not a valid input.
100+
const state: FlipbookModel = {
101+
questions: "foo\nbar\nbaz",
102+
requestedIndex: 2,
103+
};
104+
const action = jumpToQuestion("0");
105+
106+
expect(flipbookModelReducer(state, action).requestedIndex).toBe(2);
107+
});
108+
109+
it("jumps to the given question by 1-based index", () => {
110+
const state: FlipbookModel = {
111+
questions: "foo\nbar\nbaz",
112+
requestedIndex: 0,
113+
};
114+
const action = jumpToQuestion("2");
115+
116+
expect(flipbookModelReducer(state, action).requestedIndex).toBe(1);
117+
});
118+
});
78119

120+
describe("setQuestions", () => {
79121
it("replaces the questions string", () => {
80122
const model: FlipbookModel = {
81123
questions: "",
@@ -88,6 +130,40 @@ describe("flipbookModelReducer", () => {
88130
});
89131
});
90132

133+
describe("removeCurrentQuestion", () => {
134+
it("does nothing when there are no questions", () => {
135+
const model: FlipbookModel = {questions: "", requestedIndex: 0};
136+
expect(flipbookModelReducer(model, removeCurrentQuestion)).toEqual({
137+
questions: "",
138+
requestedIndex: 0,
139+
});
140+
});
141+
142+
it("removes the first question when the requestedIndex is 0", () => {
143+
const model: FlipbookModel = {questions: "one\ntwo", requestedIndex: 0};
144+
expect(flipbookModelReducer(model, removeCurrentQuestion)).toEqual({
145+
questions: "two",
146+
requestedIndex: 0,
147+
});
148+
});
149+
150+
it("removes the second question when the requestedIndex is 1", () => {
151+
const model: FlipbookModel = {questions: "one\ntwo", requestedIndex: 1};
152+
expect(flipbookModelReducer(model, removeCurrentQuestion)).toEqual({
153+
questions: "one",
154+
requestedIndex: 1,
155+
});
156+
});
157+
158+
it("removes the last question when the index is high out of bounds", () => {
159+
const model: FlipbookModel = {questions: "one\ntwo", requestedIndex: 9};
160+
expect(flipbookModelReducer(model, removeCurrentQuestion)).toEqual({
161+
questions: "one",
162+
requestedIndex: 9,
163+
});
164+
});
165+
});
166+
91167
describe("selectCurrentQuestion", () => {
92168
it("returns null when there are no questions", () => {
93169
const model = {questions: "", requestedIndex: 0};

dev/flipbook-model.ts

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,34 @@ export type FlipbookModel = {
1414
// ---------------------------------------------------------------------------
1515

1616
export type Action =
17+
| {type: "noop"}
1718
| {type: "next"}
1819
| {type: "previous"}
19-
| {type: "set-questions"; questions: string};
20+
| {type: "set-questions"; questions: string}
21+
| {type: "remove-current-question"}
22+
| {type: "jump-to-index"; index: number};
2023

2124
export const next: Action = {type: "next"};
2225

2326
export const previous: Action = {type: "previous"};
2427

28+
export const jumpToQuestion = (rawUserInput: string): Action => {
29+
if (!isPositiveInteger(rawUserInput)) {
30+
return {type: "noop"};
31+
}
32+
33+
return {
34+
type: "jump-to-index",
35+
index: parseInt(rawUserInput, 10) - 1,
36+
};
37+
};
38+
2539
export function setQuestions(questions: string): Action {
2640
return {type: "set-questions", questions};
2741
}
2842

43+
export const removeCurrentQuestion: Action = {type: "remove-current-question"};
44+
2945
// Reducer
3046
// ---------------------------------------------------------------------------
3147

@@ -34,34 +50,47 @@ export function flipbookModelReducer(
3450
action: Action,
3551
): FlipbookModel {
3652
switch (action.type) {
37-
case "next": {
53+
case "next":
54+
return updateIndex(state, (index) => index + 1);
55+
case "previous":
56+
return updateIndex(state, (index) => index - 1);
57+
case "jump-to-index":
58+
return updateIndex(state, () => action.index);
59+
case "set-questions": {
3860
return {
3961
...state,
40-
requestedIndex: clampIndex(
41-
state.requestedIndex + 1,
42-
selectQuestions(state),
43-
),
62+
questions: action.questions,
4463
};
4564
}
46-
case "previous": {
65+
case "remove-current-question": {
66+
const indexToRemove = selectCurrentQuestionIndex(state);
4767
return {
4868
...state,
49-
requestedIndex: clampIndex(
50-
state.requestedIndex - 1,
51-
selectQuestions(state),
52-
),
53-
};
54-
}
55-
case "set-questions": {
56-
return {
57-
...state,
58-
questions: action.questions,
69+
questions: state.questions
70+
.split("\n")
71+
.filter((_, i) => i !== indexToRemove)
72+
.join("\n"),
5973
};
6074
}
6175
}
6276
return state;
6377
}
6478

79+
// updateIndex immutably updates the `requestedIndex` of the given `state`,
80+
// ensuring that the resulting index is valid and in-bounds.
81+
// The given `update` function is used to determine the new index. The index
82+
// passed to `update` is the current *effective* index, which may be different
83+
// from the requested index. Unlike the requested index, the effective index is
84+
// guaranteed to be in-bounds.
85+
function updateIndex(state: FlipbookModel, update: (index: number) => number) {
86+
const currIndex = selectCurrentQuestionIndex(state);
87+
const questions = selectQuestions(state);
88+
return {
89+
...state,
90+
requestedIndex: clampIndex(update(currIndex), questions),
91+
};
92+
}
93+
6594
function clampIndex(index: number, array: unknown[]): number {
6695
if (array.length === 0) {
6796
return 0;
@@ -91,10 +120,18 @@ export const selectQuestions = cache(
91120
export const selectCurrentQuestion = cache(
92121
(state: FlipbookModel): PerseusRenderer | null => {
93122
const questions = selectQuestions(state);
94-
return questions[clampIndex(state.requestedIndex, questions)] ?? null;
123+
return questions[selectCurrentQuestionIndex(state)] ?? null;
95124
},
96125
);
97126

127+
export const selectNumQuestions = cache(
128+
(state: FlipbookModel): number => selectQuestions(state).length,
129+
);
130+
131+
export const selectCurrentQuestionIndex = (state: FlipbookModel): number => {
132+
return clampIndex(state.requestedIndex, selectQuestions(state));
133+
};
134+
98135
function parseQuestion(json): PerseusRenderer {
99136
try {
100137
return JSON.parse(json);
@@ -109,3 +146,7 @@ function parseQuestion(json): PerseusRenderer {
109146
};
110147
}
111148
}
149+
150+
function isPositiveInteger(s: string): boolean {
151+
return /^\d+$/.test(s) && +s > 0;
152+
}

dev/flipbook.tsx

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint monorepo/no-internal-import: "off", monorepo/no-relative-import: "off", import/no-relative-packages: "off" */
22
import Button from "@khanacademy/wonder-blocks-button";
3+
import Color from "@khanacademy/wonder-blocks-color";
34
import {View} from "@khanacademy/wonder-blocks-core";
45
import {Strut} from "@khanacademy/wonder-blocks-layout";
56
import Spacing from "@khanacademy/wonder-blocks-spacing";
@@ -9,12 +10,17 @@ import {useReducer, useRef} from "react";
910
import {Renderer} from "../packages/perseus/src";
1011
import {isCorrect} from "../packages/perseus/src/util";
1112

13+
import {EditableControlledInput} from "./editable-controlled-input";
1214
import {
1315
flipbookModelReducer,
1416
next,
1517
previous,
18+
removeCurrentQuestion,
19+
selectCurrentQuestionIndex,
1620
selectCurrentQuestion,
1721
setQuestions,
22+
selectNumQuestions,
23+
jumpToQuestion,
1824
} from "./flipbook-model";
1925

2026
import type {
@@ -40,6 +46,8 @@ export function Flipbook() {
4046
});
4147

4248
const question = selectCurrentQuestion(state);
49+
const numQuestions = selectNumQuestions(state);
50+
const index = selectCurrentQuestionIndex(state);
4351

4452
const noTextEntered = state.questions.trim() === "";
4553

@@ -53,15 +61,29 @@ export function Flipbook() {
5361
onChange={(e) => dispatch(setQuestions(e.target.value))}
5462
/>
5563
<Strut size={Spacing.small_12} />
56-
<View style={{flexDirection: "row"}}>
64+
<View style={{flexDirection: "row", alignItems: "baseline"}}>
5765
<Button kind="secondary" onClick={() => dispatch(previous)}>
5866
Previous
5967
</Button>
6068
<Strut size={Spacing.xxSmall_6} />
6169
<Button kind="secondary" onClick={() => dispatch(next)}>
6270
Next
6371
</Button>
72+
<Strut size={Spacing.medium_16} />
73+
<Progress
74+
zeroBasedIndex={index}
75+
total={numQuestions}
76+
onIndexChanged={(input) => dispatch(jumpToQuestion(input))}
77+
/>
78+
<Strut size={Spacing.medium_16} />
79+
<Button
80+
kind="tertiary"
81+
onClick={() => dispatch(removeCurrentQuestion)}
82+
>
83+
Discard question
84+
</Button>
6485
</View>
86+
<Strut size={Spacing.small_12} />
6587
<div style={{display: noTextEntered ? "block" : "none"}}>
6688
<h2>Instructions</h2>
6789
<ol>
@@ -101,8 +123,9 @@ function SideBySideQuestionRenderer({
101123
className="framework-perseus"
102124
style={{
103125
flexDirection: "row",
104-
padding: Spacing.xLarge_32,
105-
gap: Spacing.small_12,
126+
padding: Spacing.medium_16,
127+
gap: Spacing.medium_16,
128+
background: "#f8f8f8",
106129
}}
107130
>
108131
<GradableRenderer
@@ -137,7 +160,14 @@ function GradableRenderer(props: QuestionRendererProps) {
137160
}
138161

139162
return (
140-
<View style={{alignItems: "flex-start"}}>
163+
<View
164+
style={{
165+
alignItems: "flex-start",
166+
overflow: "hidden",
167+
background: Color.white,
168+
padding: Spacing.medium_16,
169+
}}
170+
>
141171
<Renderer
142172
ref={rendererRef}
143173
content={question.content}
@@ -158,3 +188,24 @@ function GradableRenderer(props: QuestionRendererProps) {
158188
</View>
159189
);
160190
}
191+
192+
type ProgressProps = {
193+
zeroBasedIndex: number;
194+
total: number;
195+
onIndexChanged: (rawUserInput: string) => unknown;
196+
};
197+
198+
function Progress(props: ProgressProps) {
199+
const {zeroBasedIndex, total, onIndexChanged} = props;
200+
const indexToDisplay = Math.min(total, zeroBasedIndex + 1);
201+
return (
202+
<div>
203+
<EditableControlledInput
204+
value={String(indexToDisplay)}
205+
onInput={onIndexChanged}
206+
style={{width: "4em", textAlign: "right"}}
207+
/>
208+
&nbsp;of {total}
209+
</div>
210+
);
211+
}

0 commit comments

Comments
 (0)