From b3106ab9ff60b083fd412a1db6f8e047e546552f Mon Sep 17 00:00:00 2001 From: Will Crichton Date: Wed, 28 Aug 2024 11:56:33 -0700 Subject: [PATCH] Add an option to enable the bug reporter (#46) --- crates/mdbook-quiz/src/main.rs | 7 + example/mdbook/book.toml | 3 +- js/packages/quiz-embed/src/main.tsx | 2 + js/packages/quiz/package.json | 5 +- js/packages/quiz/src/components/quiz.tsx | 131 ++++++++++-------- js/packages/quiz/src/questions/mod.tsx | 13 +- .../quiz/tests/multiple-choice.test.tsx | 56 ++++---- js/packages/quiz/tests/question.test.tsx | 25 ++-- js/packages/quiz/tests/short-answer.test.tsx | 25 ++-- js/packages/quiz/tests/tracing.test.tsx | 25 ++-- 10 files changed, 161 insertions(+), 131 deletions(-) diff --git a/crates/mdbook-quiz/src/main.rs b/crates/mdbook-quiz/src/main.rs index 590c427..c060b8f 100644 --- a/crates/mdbook-quiz/src/main.rs +++ b/crates/mdbook-quiz/src/main.rs @@ -53,6 +53,9 @@ struct QuizConfig { /// Path to a .dic file containing words to include in the spellcheck dictionary. more_words: Option, + /// If true (and telemetry is enabled) then allow users to report bugs in the frontend. + show_bug_reporter: Option, + dev_mode: bool, } @@ -170,6 +173,9 @@ impl QuizPreprocessor { if let Some(lang) = &self.config.default_language { add_data("quiz-default-language", lang)?; } + if let Some(true) = self.config.show_bug_reporter { + add_data("quiz-show-bug-reporter", "")?; + } html.push_str(">"); @@ -199,6 +205,7 @@ impl SimplePreprocessor for QuizPreprocessor { .get("more-words") .map(|value| value.as_str().unwrap().into()), spellcheck: parse_bool("spellcheck"), + show_bug_reporter: parse_bool("show-bug-reporter"), dev_mode, }; diff --git a/example/mdbook/book.toml b/example/mdbook/book.toml index d733d67..7349455 100644 --- a/example/mdbook/book.toml +++ b/example/mdbook/book.toml @@ -6,4 +6,5 @@ src = "src" title = "example" [preprocessor.quiz] -fullscreen = true \ No newline at end of file +fullscreen = true +show-bug-reporter = true \ No newline at end of file diff --git a/js/packages/quiz-embed/src/main.tsx b/js/packages/quiz-embed/src/main.tsx index 52237ab..3080ec6 100644 --- a/js/packages/quiz-embed/src/main.tsx +++ b/js/packages/quiz-embed/src/main.tsx @@ -35,6 +35,7 @@ let initQuizzes = () => { let root = ReactDOM.createRoot(el); let fullscreen = divEl.dataset.quizFullscreen !== undefined; let cacheAnswers = divEl.dataset.quizCacheAnswers !== undefined; + let showBugReporter = divEl.dataset.quizShowBugReporter !== undefined; root.render( { quiz={quiz} fullscreen={fullscreen} cacheAnswers={cacheAnswers} + showBugReporter={showBugReporter} allowRetry /> diff --git a/js/packages/quiz/package.json b/js/packages/quiz/package.json index af03e25..4ffea14 100644 --- a/js/packages/quiz/package.json +++ b/js/packages/quiz/package.json @@ -9,8 +9,11 @@ ".": { "default": "./dist/lib.js" }, - "./*": { + "./*.js": { "default": "./dist/*.js" + }, + "./*.scss": { + "default": "./dist/*.scss" } }, "type": "module", diff --git a/js/packages/quiz/src/components/quiz.tsx b/js/packages/quiz/src/components/quiz.tsx index 514fede..a7d3620 100644 --- a/js/packages/quiz/src/components/quiz.tsx +++ b/js/packages/quiz/src/components/quiz.tsx @@ -4,6 +4,7 @@ import { action, toJS } from "mobx"; import { observer, useLocalObservable } from "mobx-react"; import hash from "object-hash"; import React, { + useContext, useEffect, useLayoutEffect, useMemo, @@ -152,55 +153,54 @@ let loadState = ({ }; interface HeaderProps { - quiz: Quiz; state: QuizState; ended: boolean; } -let Header = observer(({ quiz, state, ended }: HeaderProps) => ( -
-

Quiz

-
- {state.started ? ( - !ended && ( +let Header = observer(({ state, ended }: HeaderProps) => { + let { quiz } = useContext(QuizConfigContext)!; + return ( +
+

Quiz

+
+ {state.started ? ( + !ended && ( + <> + Question{" "} + {(state.attempt === 0 + ? state.index + : state.wrongAnswers!.indexOf(state.index)) + 1}{" "} + /{" "} + {state.attempt === 0 + ? quiz.questions.length + : state.wrongAnswers!.length} + + ) + ) : ( <> - Question{" "} - {(state.attempt === 0 - ? state.index - : state.wrongAnswers!.indexOf(state.index)) + 1}{" "} - /{" "} - {state.attempt === 0 - ? quiz.questions.length - : state.wrongAnswers!.length} + {quiz.questions.length} question + {quiz.questions.length > 1 && "s"} - ) - ) : ( - <> - {quiz.questions.length} question - {quiz.questions.length > 1 && "s"} - - )} -
-
-)); + )} +
+
+ ); +}); interface AnswerReviewProps { - quiz: Quiz; state: QuizState; - name: string; nCorrect: number; onRetry: () => void; onGiveUp: () => void; } let AnswerReview = ({ - quiz, state, - name, nCorrect, onRetry, onGiveUp }: AnswerReviewProps) => { + let { quiz, name } = useContext(QuizConfigContext)!; let confirm = !state.confirmedDone && (

You can either{" "} @@ -294,15 +294,21 @@ export let useCaptureMdbookShortcuts = (capture: boolean) => { }, [capture]); }; -export interface QuizViewProps { +export interface QuizViewConfig { name: string; quiz: Quiz; fullscreen?: boolean; cacheAnswers?: boolean; allowRetry?: boolean; - onFinish?: (answers: TaggedAnswer[]) => void; + showBugReporter?: boolean; } +export type QuizViewProps = QuizViewConfig & { + onFinish?: (answers: TaggedAnswer[]) => void; +}; + +export let QuizConfigContext = React.createContext(null); + let aCode = "a".charCodeAt(0); export let generateQuestionTitles = (quiz: Quiz): string[] => { let groups: Question[][] = []; @@ -333,23 +339,27 @@ export let generateQuestionTitles = (quiz: Quiz): string[] => { }; export let QuizView: React.FC = observer( - ({ quiz, name, fullscreen, cacheAnswers, allowRetry, onFinish }) => { - let [quizHash] = useState(() => hash.MD5(quiz)); - let answerStorage = new AnswerStorage(name, quizHash); + ({ onFinish, ...config }) => { + let [quizHash] = useState(() => hash.MD5(config.quiz)); + let answerStorage = new AnswerStorage(config.name, quizHash); let questionStates = useMemo( () => - quiz.questions.map(q => { + config.quiz.questions.map(q => { let methods = getQuestionMethods(q.type); return methods.questionState?.(q.prompt, q.answer); }), - [quiz] + [config.quiz] ); let state = useLocalObservable(() => - loadState({ quiz, answerStorage, cacheAnswers }) + loadState({ + quiz: config.quiz, + answerStorage, + cacheAnswers: config.cacheAnswers + }) ); let saveToCache = () => { - if (cacheAnswers) + if (config.cacheAnswers) answerStorage.save( state.answers, state.confirmedDone, @@ -360,18 +370,18 @@ export let QuizView: React.FC = observer( // Don't allow any keyboard inputs to reach external listeners // while the quiz is active (e.g. to avoid using the search box). - let ended = state.index === quiz.questions.length; + let ended = state.index === config.quiz.questions.length; let inProgress = state.started && !ended; useCaptureMdbookShortcuts(inProgress); // Restore the user's scroll position after leaving fullscreen mode let [lastTop, setLastTop] = useState(); - let showFullscreen = inProgress && (fullscreen ?? false); + let showFullscreen = inProgress && (config.fullscreen ?? false); useLayoutEffect(() => { document.body.style.overflowY = showFullscreen ? "hidden" : "auto"; if (showFullscreen) { setLastTop(window.scrollY + 100); - } else if (fullscreen && lastTop !== undefined) { + } else if (config.fullscreen && lastTop !== undefined) { window.scrollTo(0, lastTop); } }, [showFullscreen]); @@ -389,7 +399,7 @@ export let QuizView: React.FC = observer( n => n === state.index ); if (wrongAnswerIdx === state.wrongAnswers!.length - 1) - state.index = quiz.questions.length; + state.index = config.quiz.questions.length; else state.index = state.wrongAnswers![wrongAnswerIdx + 1]; } @@ -400,11 +410,11 @@ export let QuizView: React.FC = observer( attempt: state.attempt }); - if (state.index === quiz.questions.length) { + if (state.index === config.quiz.questions.length) { let wrongAnswers = state.answers .map((a, i) => ({ a, i })) .filter(({ a }) => !a.correct); - if (wrongAnswers.length === 0 || !allowRetry) { + if (wrongAnswers.length === 0 || !config.allowRetry) { state.confirmedDone = true; } else { state.wrongAnswers = wrongAnswers.map(({ i }) => i); @@ -421,15 +431,13 @@ export let QuizView: React.FC = observer( // on first render... state.confirmedDone; - let questionTitles = generateQuestionTitles(quiz); + let questionTitles = generateQuestionTitles(config.quiz); let body = (

{state.started ? ( ended ? ( { @@ -444,12 +452,11 @@ export let QuizView: React.FC = observer( ) : ( @@ -484,18 +491,20 @@ export let QuizView: React.FC = observer( let wrapperRef = useRef(); return ( -
-
- {showFullscreen && ( - <> - {exitButton} - - - )} -
- {body} + +
+
+ {showFullscreen && ( + <> + {exitButton} + + + )} +
+ {body} +
-
+ ); } ); diff --git a/js/packages/quiz/src/questions/mod.tsx b/js/packages/quiz/src/questions/mod.tsx index dfd0dbf..20997c1 100644 --- a/js/packages/quiz/src/questions/mod.tsx +++ b/js/packages/quiz/src/questions/mod.tsx @@ -1,13 +1,13 @@ import classNames from "classnames"; import _ from "lodash"; -import React, { useId, useMemo, useRef, useState } from "react"; +import React, { useContext, useId, useMemo, useRef, useState } from "react"; import { type RegisterOptions, useForm } from "react-hook-form"; import type { Question } from "../bindings/Question"; import type { Quiz } from "../bindings/Quiz"; import { MarkdownView } from "../components/markdown"; import { MoreInfo } from "../components/more-info"; -import { useCaptureMdbookShortcuts } from "../lib"; +import { QuizConfigContext, useCaptureMdbookShortcuts } from "../lib"; import { MultipleChoiceMethods } from "./multiple-choice"; import { ShortAnswerMethods } from "./short-answer"; import { TracingMethods } from "./tracing"; @@ -113,7 +113,6 @@ we can better improve the surrounding text. `.trim(); interface QuestionViewProps { - quizName: string; multipart: Quiz["multipart"]; question: Question; index: number; @@ -146,7 +145,6 @@ let MultipartContext = ({ ); export let QuestionView: React.FC = ({ - quizName, multipart, question, index, @@ -155,6 +153,7 @@ export let QuestionView: React.FC = ({ questionState, onSubmit }) => { + let { name: quizName, showBugReporter } = useContext(QuizConfigContext)!; let start = useMemo(now, [quizName, question, index]); let ref = useRef(null); let [showExplanation, setShowExplanation] = useState(false); @@ -209,7 +208,7 @@ export let QuestionView: React.FC = ({ /> )} - {window.telemetry && ( + {window.telemetry && showBugReporter && ( )}
@@ -266,7 +265,6 @@ interface AnswerViewProps { } export let AnswerView: React.FC = ({ - quizName, multipart, question, index, @@ -275,6 +273,7 @@ export let AnswerView: React.FC = ({ correct, showCorrect }) => { + let { name: quizName, showBugReporter } = useContext(QuizConfigContext)!; let methods = getQuestionMethods(question.type); let questionClass = questionNameToCssClass(question.type); @@ -309,7 +308,7 @@ export let AnswerView: React.FC = ({

Question {title}

{multipartView} - {window.telemetry && ( + {window.telemetry && showBugReporter && ( )} diff --git a/js/packages/quiz/tests/multiple-choice.test.tsx b/js/packages/quiz/tests/multiple-choice.test.tsx index 07485e0..8ec5de7 100644 --- a/js/packages/quiz/tests/multiple-choice.test.tsx +++ b/js/packages/quiz/tests/multiple-choice.test.tsx @@ -4,7 +4,11 @@ import React from "react"; import { beforeEach, describe, expect, it } from "vitest"; import type { MultipleChoice } from "../src/bindings/MultipleChoice"; -import { MultipleChoiceMethods, QuestionView } from "../src/lib"; +import { + MultipleChoiceMethods, + QuestionView, + QuizConfigContext +} from "../src/lib"; import { submitButton } from "./utils"; describe("MultipleChoice", () => { @@ -22,18 +26,19 @@ describe("MultipleChoice", () => { question.answer ); render( - { - submitted = answer; - }} - /> + + { + submitted = answer; + }} + /> + ); await waitFor(() => screen.getByText("Hello world")); }); @@ -71,18 +76,19 @@ describe("MultipleChoice multi-answer", () => { question.answer ); render( - { - submitted = answer; - }} - /> + + { + submitted = answer; + }} + /> + ); await waitFor(() => screen.getByText("Hello world")); }); diff --git a/js/packages/quiz/tests/question.test.tsx b/js/packages/quiz/tests/question.test.tsx index 380dba6..180b551 100644 --- a/js/packages/quiz/tests/question.test.tsx +++ b/js/packages/quiz/tests/question.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { beforeEach, describe, expect, it } from "vitest"; import type { Question } from "../src/bindings/Question"; -import { QuestionView } from "../src/lib"; +import { QuestionView, QuizConfigContext } from "../src/lib"; import { submitButton } from "./utils"; describe("Question prompt for explanation", () => { @@ -19,17 +19,18 @@ describe("Question prompt for explanation", () => { beforeEach(async () => { submitted = null; render( - { - submitted = answer; - }} - /> + + { + submitted = answer; + }} + /> + ); await waitFor(() => screen.getByText("Hello world")); }); diff --git a/js/packages/quiz/tests/short-answer.test.tsx b/js/packages/quiz/tests/short-answer.test.tsx index 1c2bc96..2110240 100644 --- a/js/packages/quiz/tests/short-answer.test.tsx +++ b/js/packages/quiz/tests/short-answer.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { beforeEach, describe, expect, it } from "vitest"; import type { ShortAnswer } from "../src/bindings/ShortAnswer"; -import { QuestionView } from "../src/lib"; +import { QuestionView, QuizConfigContext } from "../src/lib"; import { submitButton } from "./utils"; describe("ShortAnswer", () => { @@ -18,17 +18,18 @@ describe("ShortAnswer", () => { beforeEach(async () => { submitted = null; render( - { - submitted = answer; - }} - /> + + { + submitted = answer; + }} + /> + ); await waitFor(() => screen.getByText("Hello world")); }); diff --git a/js/packages/quiz/tests/tracing.test.tsx b/js/packages/quiz/tests/tracing.test.tsx index 1b75760..6244e8d 100644 --- a/js/packages/quiz/tests/tracing.test.tsx +++ b/js/packages/quiz/tests/tracing.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { beforeEach, describe, expect, it } from "vitest"; import type { Tracing } from "../src/bindings/Tracing"; -import { QuestionView } from "../src/lib"; +import { QuestionView, QuizConfigContext } from "../src/lib"; import { submitButton } from "./utils"; describe("Tracing", () => { @@ -18,17 +18,18 @@ describe("Tracing", () => { beforeEach(async () => { submitted = null; render( - { - submitted = answer; - }} - /> + + { + submitted = answer; + }} + /> + ); await waitFor(() => screen.getByText("Question 1")); });