Permalink
Browse files

Split out most exercise interface code

There is no scenario in which this is not a bitch to review. Sorry.

TODO: make local mode work again
TODO: get rid of utils/hints.js and merge with exercise-package/interface.js

Test Plan:
Did problems in Addition 1 locally and took hints and saw buttons update and lost leaves.
  • Loading branch information...
1 parent 2921406 commit cbbd22da129cb8007ff95ff49fed08456856fa93 @spicyj spicyj committed Mar 23, 2013
Showing with 487 additions and 531 deletions.
  1. +1 −1 exercises/khan-exercise.html
  2. +368 −14 interface.js
  3. +118 −516 khan-exercise.js
View
2 exercises/khan-exercise.html
@@ -23,7 +23,7 @@
<div id='hintsarea'></div>
</div>
<div id="answer_area_wrap"><div id="answer_area">
- <form id="answerform" action="/exercisedashboard" method="get" name="answerform">
+ <form id="answerform" name="answerform">
<input type="submit" style="position: absolute; left: -9999px; width: 1px; height: 1px">
<div id="answercontent" class="info-box">
<span id="examples-show">Acceptable formats</span>
View
382 interface.js
@@ -7,37 +7,386 @@
*/
(function() {
+_.extend(Exercises, {
+ khanExercisesUrlBase: "/khan-exercises/",
-// (khan-exercises in local mode will stub this out)
-// TODO(alpert): Is this icky?
-Exercises.getCurrentFramework = function() {
- return Exercises.practiceExercise.getFramework();
-};
+ getCurrentFramework: function() {
+ // TODO(alpert): Is this icky?
+ return Exercises.practiceExercise.getFramework();
+ }
+
+ guessLog: undefined,
+ userActivityLog: undefined
+});
+
+
+var originalCheckAnswerText,
+ userExercise,
+ problemNum,
+
+ attempts,
+ numHints,
+ hintsUsed,
+ lastAttemptOrHint;
$(Exercises)
.bind("problemTemplateRendered", problemTemplateRendered)
+ .bind("newProblem", newProblem)
+ .bind("hintUsed", hintUsed)
.bind("readyForNextProblem", readyForNextProblem)
.bind("warning", warning)
.bind("upcomingExercise", upcomingExercise)
.bind("gotoNextProblem", gotoNextProblem)
.bind("updateUserExercise", updateUserExercise)
- .bind("enableCheckAnswer", enableCheckAnswer)
- .bind("disableCheckAnswer", disableCheckAnswer)
.bind("clearExistingProblem", clearExistingProblem);
function problemTemplateRendered() {
+ // Setup appropriate img URLs
+ $("#issue-throbber").attr("src",
+ Exercises.khanExercisesUrlBase + "css/images/throbber.gif");
+
+ // 'Check Answer' or 'Submit Answer'
+ originalCheckAnswerText = $("#check-answer-button").val();
+
+ // Solution submission
+ $("#check-answer-button").click(handleCheckAnswer);
+ $("#answerform").submit(handleCheckAnswer);
+
+ // Hint button
+ $("#hint").click(handleHint);
+
+ // Next question button
+ $("#next-question-button").click(function() {
+ $(Exercises).trigger("gotoNextProblem");
+
+ // Disable next question button until next time
+ // TODO(alpert): Why? Is blurring not enough?
+ $(this)
+ .attr("disabled", true)
+ .addClass("buttonDisabled");
+ });
+
+ // If happy face is clicked, pass click on through.
+ $("#positive-reinforcement").click(function() {
+ $("#next-question-button").click();
+ });
+
+ // Let users close the warning bar when appropriate
+ $("#warning-bar-close a").click(function(e) {
+ e.preventDefault();
+ $("#warning-bar").fadeOut("slow");
+ });
+
+ // Scratchpad toggle
+ $("#scratchpad-show").click(function(e) {
+ e.preventDefault();
+ Khan.scratchpad.toggle();
+
+ if (userExercise.user) {
+ window.localStorage["scratchpad:" + userExercise.user] =
+ Khan.scratchpad.isVisible();
+ }
+ });
+
var framework = Exercises.getCurrentFramework();
if (framework === "perseus") {
+ // TODO(alpert)
} else if (framework === "khan-exercises") {
$(Khan).trigger("problemTemplateRendered");
}
}
+function newProblem(e, data) {
+ Exercises.guessLog = [];
+ Exercises.userActivityLog = [];
+
+ attempts = 0;
+ numHints = data.numHints;
+ hintsUsed = 0;
+ lastAttemptOrHint = new Date().getTime();
+}
+
+
+function handleCheckAnswer() {
+ var framework = Exercises.getCurrentFramework();
+
+ // Stop if the form is already disabled and we're waiting for a response
+ // (in assessment mode, for instance).
+ if ($("#answercontent input").not("#hint, #next-question-button")
+ .is(":disabled")) {
+ return false;
+ }
+
+ var score = Khan.scoreInput();
+
+ // Stop if the user didn't enter a response
+ if (score.empty) {
+ return false;
+ }
+
+ Exercises.guessLog.push(score.guess);
+ Exercises.userActivityLog.push([
+ score.correct ? "correct-activity" : "incorrect-activity",
+ JSON.stringify(score.guess), timeTaken]);
+
+ // Update interface corresponding to correctness
+ if (Exercises.assessmentMode) {
+ disableCheckAnswer();
+ } else if (score.correct) {
+ // Correct answer, so show the next question button.
+ $("#check-answer-button").hide();
+ $("#next-question-button")
+ .removeAttr("disabled")
+ .removeClass("buttonDisabled")
+ .show()
+ .focus();
+ $("#positive-reinforcement").show();
+ } else {
+ // Wrong answer. Enable all the input elements
+ //$("#answercontent input").not("#hint")
+ // .removeAttr("disabled");
+
+ $("#check-answer-button")
+ .parent() // .check-answer-wrapper makes shake behave
+ .effect("shake", {times: 3, distance: 5}, 480)
+ .val("Try Again");
+
+ // Is this a message to be shown?
+ if (score.message != null) {
+ $("#check-answer-results > p").html(score.message).tmpl().show();
+ } else {
+ $("#check-answer-results > p").hide();
+ }
+
+ if (framework === "perseus") {
+ // TODO(alpert)?
+ } else if (framework === "khan-exercises") {
+ Khan.refocusSolutionInput();
+ }
+ }
+
+ if (score.correct) {
+ $(Exercises).trigger("problemDone");
+ }
+
+ var curTime = new Date().getTime();
+ var timeTaken = Math.round((curTime - lastAttemptOrHint) / 1000);
+ lastAttemptOrHint = curTime;
+
+ // Save the problem results to the server
+ var data = buildAttemptData(score.correct, ++attempts,
+ JSON.stringify(score.guess), timeTaken);
+
+ request("problems/" + problemNum + "/attempt", data).then(function() {
+ // TODO: Save locally if offline
+ $(Khan).trigger("attemptSaved");
+ }, function(xhr) {
+ // Alert any listeners of the error before reload
+ $(Exercises).trigger("attemptError");
+
+ if (xhr && xhr.readyState === 0) {
+ // This path gets called when there is a broken pipe during
+ // page unload- browser navigating away during ajax request
+ // See http://stackoverflow.com/a/1370383.
+ return;
+ }
+
+ // Error during submit. Disable the page and ask users to
+ // reload in an attempt to get updated data.
+
+ // Hide the page so users don't continue, then warn the user about the
+ // problem and encourage reloading the page
+ $("#problem-and-answer").css("visibility", "hidden");
+ $(Exercises).trigger("warning",
+ "This page is out of date. You need to <a href='" +
+ _.escape(window.location.href) + "'>refresh</a>, but " +
+ "don't worry, you haven't lost progress. If you think " +
+ "this is a mistake, <a href='http://www.khanacademy.org/" +
+ "reportissue?type=Defect&issue_labels='>tell us</a>.");
+ });
+
+ $(Exercises).trigger("checkAnswer", {
+ correct: score.correct,
+
+ // Determine if this attempt qualifies as fast completion
+ fast: (typeof userExercise !== "undefined" &&
+ userExercise.secondsPerFastProblem >= timeTaken)
+ });
+
+ return false;
+}
+
+function handleHint() {
+ var framework = Exercises.getCurrentFramework();
+
+ if (framework === "perseus") {
+ } else if (framework === "khan-exercises") {
+ $(Khan).trigger("showHint");
+ }
+}
+
+function hintUsed() {
+ // Grow the scratchpad to cover the new hint
+ Khan.scratchpad.resize();
+
+ hintsUsed++;
+ var hintsLeft = numHints - hintsUsed;
+
+ // TODO(alpert): Get rid of hints.js; it's silly
+ var $hintButton = $("#hint");
+ var stepsLeft = hintsLeft === 1 ? "1 hint left" :
+ hintsLeft + " hints left";
+ $hintButton.val($hintButton.data("buttonText") ||
+ "I'd like another hint (" + stepsLeft + ")");
+
+ // If there aren't any more hints, disable the get hint button
+ if (hintsLeft === 0) {
+ $hintButton.attr("disabled", true);
+ $(Exercises).trigger("allHintsUsed");
+ }
+
+ var curTime = new Date().getTime();
+ var timeTaken = Math.round((curTime - lastAttemptOrHint) / 1000);
+ lastAttemptOrHint = curTime;
+
+ var answeredCorrectly = $("#next-question-button").is(":visible");
+ if (!userExercise.readOnly && !answeredCorrectly) {
+ Exercises.userActivityLog.push(["hint-activity", "0", timeTaken]);
+
+ // Resets the streak and logs history for exercise viewer
+ // Don't do anything on success or failure; silently failing is ok here
+ request("problems/" + problemNum + "/hint",
+ buildAttemptData(false, attempts, "hint", timeTaken));
+ }
+}
+
+// Build the data to pass to the server
+function buildAttemptData(pass, attemptNum, attemptContent, timeTaken) {
+ var framework = Exercises.getCurrentFramework();
+ var data;
+
+ if (framework === "perseus") {
+ // TODO(alpert)
+ data = {};
+ } else if (framework === "khan-exercises") {
+ data = _.extend({
+ complete: pass === true ? 1 : 0
+ }, Khan.getSeedInfo());
+ }
+
+ _.extend(data, {
+ // Ask for camel casing in returned response
+ casing: "camel",
+
+ // Whether we're moving to the next problem (i.e., correctness)
+ count_hints: hintsUsed,
+ time_taken: timeTaken,
+
+ // How many times the problem was attempted
+ attempt_number: attemptNum,
+
+ // The answer the user gave
+ attempt_content: attemptContent,
+
+ // Whether we're currently in review mode
+ review_mode: Exercises.reviewMode ? 1 : 0,
+
+ // Whether we are currently working on a topic, as opposed to an exercise
+ topic_mode: (!Exercises.reviewMode && !Exercises.practiceMode) ? 1 : 0,
+
+ // The current card data
+ card: JSON.stringify(Exercises.currentCard),
+
+ // Unique ID of the cached stack
+ stack_uid: Exercises.completeStack.getUid(),
+
+ // The current topic, if any
+ topic_id: Exercises.topic && Exercises.topic.id,
+
+ // How many cards the user has already done
+ cards_done: Exercises.completeStack.length,
+
+ // How many cards the user has left to do
+ cards_left: Exercises.incompleteStack.length - 1,
+
+ // Custom stack ID if it exists
+ custom_stack_id: Exercises.completeStack.getCustomStackID(),
+
+ // The user assessment key if in assessmentMode
+ user_assessment_key: Exercises.userAssessmentKey
+ });
+
+ return data;
+}
+
+
+var attemptHintQueue = jQuery({});
+
+// If there are any requests left in the queue when the window unloads then we
+// will have permanently lost their answers and will need to clear the session
+// cache, to make sure we don't override what is passed down from the servers
+$(window).unload(function() {
+ if (attemptHintQueue.queue().length) {
+ $(Exercises).trigger("attemptError");
+ }
+});
+
+function request(method, data) {
+ var apiBaseUrl = (Exercises.assessmentMode ?
+ "/api/v1/user/assessment/exercises" : "/api/v1/user/exercises");
+
+ var params = {
+ // Do a request to the server API
+ url: apiBaseUrl + "/" + userExercise.exerciseModel.name + "/" + method,
+ type: "POST",
+ data: data,
+ dataType: "json"
+ };
+
+ var deferred = $.Deferred();
+
+ attemptHintQueue.queue(function(next) {
+ $.ajax(params).then(function(data, textStatus, jqXHR) {
+ deferred.resolve(data, textStatus, jqXHR);
+
+ // Tell any listeners that we now have new userExercise data
+ $(Exercises).trigger("updateUserExercise", {
+ userExercise: data,
+ source: "serverResponse"
+ });
+ }, function(jqXHR, textStatus, errorThrown) {
+ // Execute passed error function first in case it wants different
+ // behavior depending upon the length of the request queue
+ // TODO(alpert): Huh? Don't think this matters.
+ deferred.reject(jqXHR, textStatus, errorThrown);
+
+ // Clear the queue so we don't spit out a bunch of queued up
+ // requests after the error
+ attemptHintQueue.clearQueue();
+ }).always(function() {
+ $(Exercises).trigger("apiRequestEnded");
+ next();
+ });
+ });
+
+ // Trigger an apiRequestStarted event here, and not in the queued function
+ // because listeners should know an API request is waiting as soon as it
+ // gets queued up.
+ $(Exercises).trigger("apiRequestStarted");
+
+ return deferred.promise();
+}
+
+
function readyForNextProblem(e, data) {
+ userExercise = data.userExercise;
+ problemNum = userExercise.totalDone + 1;
+
var framework = Exercises.getCurrentFramework();
if (framework === "perseus") {
+ // TODO(alpert)
} else if (framework === "khan-exercises") {
$(Khan).trigger("readyForNextProblem", data);
}
@@ -60,23 +409,28 @@ function warning(e, message, showClose) {
function upcomingExercise(e, data) {
var framework = Exercises.getCurrentFramework();
- // TODO(alpert): Preload perseus problems in topic mode too
- if (framework === "khan-exercises") {
+ if (framework === "perseus") {
+ // TODO(alpert)
+ } else if (framework === "khan-exercises") {
$(Khan).trigger("upcomingExercise", data);
}
}
function gotoNextProblem() {
var framework = Exercises.getCurrentFramework();
- if (framework === "khan-exercises") {
+ if (framework === "perseus") {
+ // TODO(alpert)
+ } else if (framework === "khan-exercises") {
$(Khan).trigger("gotoNextProblem");
}
}
function updateUserExercise(e, data) {
var framework = Exercises.getCurrentFramework();
- if (framework === "khan-exercises") {
+ if (framework === "perseus") {
+ // TODO(alpert)
+ } else if (framework === "khan-exercises") {
$(Khan).trigger("updateUserExercise", data);
}
}
@@ -111,11 +465,12 @@ function clearExistingProblem() {
$("#positive-reinforcement").hide();
// Wipe out any previous problem
- if (framework === "khan-exercises") {
+ if (framework === "perseus") {
+ // TODO(alpert): Do we have cleanup to do?
+ } else if (framework === "khan-exercises") {
$(Khan).trigger("cleanupProblem");
}
$("#workarea, #hintsarea").empty();
- $("#hint").attr("disabled", false);
// Take off the event handlers for disabling check answer; we'll rebind
// if we actually want them
@@ -124,5 +479,4 @@ function clearExistingProblem() {
Khan.scratchpad.clear();
}
-
})();
View
634 khan-exercise.js
@@ -134,18 +134,14 @@ var Khan = (function() {
return Math.abs(crc ^ (-1));
},
- userExercise = undefined,
+ userExercise,
// Check to see if we're in local mode
localMode = typeof Exercises === "undefined",
// Set in prepareSite when Exercises.init() has already been called
assessmentMode,
- // The main server we're connecting to for saving data
- server = typeof apiServer !== "undefined" ? apiServer :
- localMode ? "http://localhost:8080" : "",
-
// The ID, filename, and name of the exercise -- these will only be set here in localMode
exerciseId = ((/([^\/.]+)(?:\.html)?$/.exec(window.location.pathname) || [])[1]) || "",
exerciseFile = exerciseId + ".html",
@@ -202,21 +198,13 @@ var Khan = (function() {
// For saving problems to the server
hintsUsed,
- lastAction,
- attempts,
// Bug-hunting "undefined" attempt content
debugLogLog = ["start of log"],
debugLog = function(l) {
debugLogLog.push(l);
},
- guessLog,
- userActivityLog,
-
- // A map of jQuery queues for serially sending and receiving AJAX requests.
- requestQueue = {},
-
// Dictionary of loading and loaded exercises; keys are exercise IDs,
// values are promises that are resolved when the exercise is loaded
exerciseFilePromises = {},
@@ -226,21 +214,15 @@ var Khan = (function() {
// site, immediately)
modulePromises = {},
- urlBase = typeof urlBaseOverride !== "undefined" ? urlBaseOverride :
- localMode ? "../" : "/khan-exercises/",
+ urlBase = localMode ? "../" : "/khan-exercises/",
// In local mode, we use khan-exercises local copy of the /images
// directory. But in production (on www.khanacademy.org), we use
// the canonical location of images, which is under '/'.
- imageBase = ((typeof urlBaseOverride !== "undefined" || localMode)
- ? (urlBase + "images/") : ("/images/"));
-
+ imageBase = localMode ? urlBase + "images/" : "/images/",
lastFocusedSolutionInput = null,
- // "Check answer" or in assessmentMode "Submit answer" - set in prepareSite
- originalCheckAnswerText = "",
-
issueError = "Communication with GitHub isn't working. Please file " +
"the issue manually at <a href=\"" +
"http://github.com/Khan/khan-exercises/issues/new\">GitHub</a>. " +
@@ -621,7 +603,13 @@ var Khan = (function() {
})).data("video", video);
},
- renderInSidebar: function() {
+ render: function() {
+ if (localMode) {
+ // Templates isn't available locally and we won't have any
+ // related videos to show anyway
+ return;
+ }
+
var container = $(".related-video-box");
var jel = container.find(".related-video-list");
jel.empty();
@@ -669,20 +657,36 @@ var Khan = (function() {
{height: (isMouseEnter ? captionHeight : 0)},
options);
});
- },
-
- render: function() {
- // don't try to render if templates aren't present (dev mode)
- if (!window.Templates) return;
-
- this.renderInSidebar();
}
},
showSolutionButtonText: function() {
return hintsUsed ? "Show next step (" + hints.length + " left)" : "Show Solution";
- }
+ },
+ getSeedInfo: function() {
+ return {
+ // A hash representing the exercise version
+ sha1: typeof userExercise !== "undefined" ?
+ userExercise.exerciseModel.sha1 : exerciseId,
+ seed: problemSeed,
+ problem_type: problemID
+ };
+ },
+
+ scoreInput: function() {
+ var guess = getAnswer();
+ var pass = validator(guess);
+ var empty = checkIfAnswerEmpty(guess) || checkIfAnswerEmpty(pass);
+
+ // Really disentangling the true/false/""/"..." mess? Incroyable!
+ return {
+ empty: empty,
+ correct: pass === true,
+ message: typeof pass === "string" ? pass : null,
+ guess: guess
+ };
+ }
};
// see line 183. this ends the main Khan module
@@ -714,17 +718,6 @@ var Khan = (function() {
}
function onjQueryLoaded() {
- // If there are any requests left in the queue when the window unloads
- // then we will have permanently lost their answers and will need to
- // clear the session cache, to make sure we don't override what is
- // passed down from the servers
- $(window).unload(function() {
- if(requestQueue["attempt_hint_queue"] &&
- requestQueue["attempt_hint_queue"].queue().length) {
- $(Exercises).trigger("attemptError");
- }
- });
-
// Initialize to an empty jQuery set
exercises = $();
@@ -908,11 +901,6 @@ var Khan = (function() {
exerciseName = userExercise.exerciseModel.displayName;
exerciseFile = userExercise.exerciseModel.fileName;
- // TODO(eater): remove this once all of the exercises in the datastore have filename properties
- if (exerciseFile == null || exerciseFile == "") {
- exerciseFile = exerciseId + ".html";
- }
-
function finishRender() {
// Get all problems of this exercise type...
// TODO(alpert): What happens if multiple summatives in topic mode
@@ -938,7 +926,6 @@ var Khan = (function() {
// Generate a new problem
makeProblem(typeOverride, seedOverride);
-
}
startLoadingExercise(exerciseId, exerciseName, exerciseFile).then(
@@ -997,8 +984,10 @@ var Khan = (function() {
function checkIfAnswerEmpty(guess) {
- return $.trim(guess) === "" ||
- (guess instanceof Array && $.trim(guess.join("").replace(/,/g, "")) === "");
+ // If multiple-answer, join all responses and check if that's empty
+ // Remove commas left by joining nested arrays in case multiple-answer is nested
+ return $.trim(guess) === "" || (guess instanceof Array &&
+ $.trim(guess.join("").replace(/,/g, "")) === "");
}
function makeProblem(id, seed) {
@@ -1210,8 +1199,6 @@ var Khan = (function() {
// Generate a type of problem
// (this includes possibly generating the multiple choice problems,
// if this fails then we will need to try generating another one.)
- guessLog = [];
- userActivityLog = [];
debugLog("decided on answer type " + answerType);
answerData = Khan.answerTypes[answerType].setup(solutionarea, solution);
@@ -1275,10 +1262,8 @@ var Khan = (function() {
// save a normal JS array of hints so we can shift() through them later
hints = hints.tmpl().children().get();
- if (hints.length === 0) {
- // Disable the get hint button
- $("#hint").attr("disabled", true);
- }
+ // Enable/disable the get hint button
+ $("#hint").attr("disabled", hints.length === 0);
// Hook out for exercise test runner
if (localMode && parent !== window && typeof parent.jQuery !== "undefined") {
@@ -1335,7 +1320,7 @@ var Khan = (function() {
height: "0",
"border-left": "6px solid transparent",
"border-right": "6px solid transparent",
- position: "absolute",
+ position: "absolute"
},
scrubber1 = $("<div>")
@@ -1365,8 +1350,7 @@ var Khan = (function() {
.data("hint", false)
.appendTo(timelineEvents);
- var hintNumber = 0,
- answerNumber = 1;
+ var hintNumber = 0;
/* value[0]: css class
* value[1]: guess
@@ -1465,13 +1449,6 @@ var Khan = (function() {
var states = timelineEvents.children(".user-activity"),
currentSlide = Math.min(states.length - 1, 1),
numSlides = states.length,
- firstHintIndex = timeline.find(".hint-activity:first")
- .index(".user-activity"),
- lastHintIndex = timeline.find(".hint-activity:last")
- .index(".user-activity"),
- totalHints = timeline.find(".hint-activity:last")
- .index(".hint-activity"),
- hintButton = $("#hint"),
timelineMiddle = timeline.width() / 2,
realHintsArea = $("#hintsarea"),
realWorkArea = $("#workarea"),
@@ -1570,7 +1547,7 @@ var Khan = (function() {
};
var activate = function(slideNum) {
- var hint, thisState,
+ var thisState,
thisSlide = states.eq(slideNum),
fadeTime = 150;
@@ -1620,7 +1597,7 @@ var Khan = (function() {
// Allow users to use arrow keys to move left and right in the
// timeline
- $(document).keydown(function(event) {
+ $(document).keydown(function() {
if (event.keyCode === 37) { // left
currentSlide -= 1;
} else if (event.keyCode === 39) { // right
@@ -1638,7 +1615,7 @@ var Khan = (function() {
});
// Allow users to click on points of the timeline
- $(states).click(function(event) {
+ $(states).click(function() {
var index = $(this).index("#timeline .user-activity");
currentSlide = index;
@@ -1647,7 +1624,7 @@ var Khan = (function() {
return false;
});
- $("#previous-step").click(function(event) {
+ $("#previous-step").click(function() {
if (currentSlide > 0) {
currentSlide -= 1;
activate(currentSlide);
@@ -1656,7 +1633,7 @@ var Khan = (function() {
return false;
});
- $("#next-step").click(function(event) {
+ $("#next-step").click(function() {
if (currentSlide < numSlides - 1) {
currentSlide += 1;
activate(currentSlide);
@@ -1665,11 +1642,11 @@ var Khan = (function() {
return false;
});
- $("#next-problem").click(function(event) {
+ $("#next-problem").click(function() {
window.location.href = userExercise.nextProblemUrl;
});
- $("#previous-problem").click(function(event) {
+ $("#previous-problem").click(function() {
if (!$(this).data("disabled")) {
window.location.href = userExercise.previousProblemUrl;
}
@@ -1721,12 +1698,15 @@ var Khan = (function() {
if (!Khan.query.activity) {
var historyURL = debugURL + "&seed=" + problemSeed + "&activity=";
- $("<a>Problem history</a>").attr("href", "javascript:").click(function(event) {
- window.location.href = historyURL + encodeURIComponent(JSON.stringify(userActivityLog));
+ $("<a>Problem history</a>").attr("href", "javascript:").click(function() {
+ window.location.href = historyURL + encodeURIComponent(
+ JSON.stringify(Exercises.userActivityLog));
}).appendTo(links);
} else {
$("<a>Random problem</a>")
- .attr("href", window.location.protocol + "//" + window.location.host + window.location.pathname + "?debug")
+ .attr("href", window.location.protocol + "//" +
+ window.location.host + window.location.pathname +
+ "?debug")
.appendTo(links);
}
@@ -1828,12 +1808,12 @@ var Khan = (function() {
}
hintsUsed = 0;
- attempts = 0;
- lastAction = (new Date).getTime();
$("#hint").val("I'd like a hint");
- $(Exercises).trigger("newProblem");
+ $(Exercises).trigger("newProblem", {
+ numHints: hints.length
+ });
// If the textbox is empty disable "Check Answer" button
// Note: We don't do this for multiple choice, number line, etc.
@@ -1877,225 +1857,11 @@ var Khan = (function() {
}
function prepareSite() {
- // TODO(david): Don't add homepage elements with "exercise" class
- exercises = exercises.add($("div.exercise").detach());
-
- // Setup appropriate img URLs
- $("#issue-throbber")
- .attr("src", urlBase + "css/images/throbber.gif");
-
- // Change form target to the current page so errors do not kick us
- // to the dashboard
- $("#answerform").attr("action", window.location.href);
-
- // Watch for a solution submission
- originalCheckAnswerText = $("#check-answer-button").val()
- $("#check-answer-button").click(handleSubmit);
- $("#answerform").submit(handleSubmit);
-
// Grab example answer format container
examples = $("#examples");
assessmentMode = !localMode && Exercises.assessmentMode;
- // Build the data to pass to the server
- function buildAttemptData(pass, attemptNum, attemptContent, curTime) {
- var timeTaken = Math.round((curTime - lastAction) / 1000);
-
- if (attemptContent !== "hint") {
- userActivityLog.push([pass ? "correct-activity" : "incorrect-activity", attemptContent, timeTaken]);
- } else {
- userActivityLog.push(["hint-activity", "0", timeTaken]);
- }
-
- return {
- // The user answered correctly
- complete: pass === true ? 1 : 0,
-
- // The user used a hint
- count_hints: hintsUsed,
-
- // How long it took them to complete the problem
- time_taken: timeTaken,
-
- // How many times the problem was attempted
- attempt_number: attemptNum,
-
- // The answer the user gave
- // TODO: Get the real provided answer
- attempt_content: attemptContent,
-
- // A hash representing the exercise
- // TODO: Populate this from somewhere
- sha1: typeof userExercise !== "undefined" ? userExercise.exerciseModel.sha1 : exerciseId,
-
- // The seed that was used for generating the problem
- seed: problemSeed,
-
- // The seed that was used for generating the problem
- problem_type: problemID,
-
- // Whether we're currently in review mode
- review_mode: (!localMode && Exercises.reviewMode) ? 1 : 0,
-
- // Whether we are currently working on a topic, as opposed to an exercise
- topic_mode: (!localMode && !Exercises.reviewMode && !Exercises.practiceMode) ? 1 : 0,
-
- // Request camelCasing in returned response
- casing: "camel",
-
- // The current card data
- card: !localMode && JSON.stringify(Exercises.currentCard),
-
- // Unique ID of the cached stack
- stack_uid: !localMode && Exercises.completeStack.getUid(),
-
- // The current topic, if any
- topic_id: !localMode && Exercises.topic && Exercises.topic.id,
-
- // How many cards the user has already done
- cards_done: !localMode && Exercises.completeStack.length,
-
- // How many cards the user has left to do
- cards_left: !localMode && (Exercises.incompleteStack.length - 1),
-
- // Custom stack ID if it exists
- custom_stack_id: !localMode && Exercises.completeStack.getCustomStackID(),
-
- // The user assessment key if in assessmentMode
- user_assessment_key: !localMode && Exercises.userAssessmentKey
- };
- }
-
- function handleSubmit() {
- var guess = getAnswer();
- var pass = validator(guess);
-
- // Stop if the user didn't enter a response
- // If multiple-answer, join all responses and check if that's empty
- // Remove commas left by joining nested arrays in case multiple-answer is nested
-
- if (checkIfAnswerEmpty(guess) || checkIfAnswerEmpty(pass)) {
- return false;
- } else {
- guessLog.push(guess);
- }
-
- // Stop if the form is already disabled and we're waiting for a response.
- if ($("#answercontent input").not("#hint,#next-question-button").is(":disabled")) {
- return false;
- }
-
- if(!assessmentMode) {
- $("#answercontent input").not("#check-answer-button, #hint")
- .attr("disabled", "disabled");
- $("#check-answer-results p").hide();
-
- var checkAnswerButton = $("#check-answer-button");
-
- // If incorrect, warn the user and help them in any way we can
- if (pass !== true) {
- checkAnswerButton
- .parent() // .check-answer-wrapper makes shake behave
- .effect("shake", {times: 3, distance: 5}, 480)
- .val("Try Again");
-
- // Is this a message to be shown?
- if (typeof pass === "string") {
- $("#check-answer-results .check-answer-message")
- .html(pass).tmpl().show();
- }
-
- // Refocus text field so user can type a new answer
- if (lastFocusedSolutionInput != null) {
- setTimeout(function() {
- var focusInput = $(lastFocusedSolutionInput);
-
- if (!focusInput.is(":disabled")) {
- // focus should always work; hopefully select
- // will work for text fields
- focusInput.focus();
- if (focusInput.is("input:text")) {
- focusInput.select();
- }
- }
- }, 1);
- }
- }
- }
-
- if (pass === true) {
- // Problem has been completed but pending data request
- // being sent to server.
- $(Exercises).trigger("problemDone");
- }
-
- // Save the problem results to the server
- var curTime = new Date().getTime();
- var data = buildAttemptData(pass, ++attempts, JSON.stringify(guess), curTime);
- debugLog("attempt " + JSON.stringify(data));
-
- request("problems/" + problemNum + "/attempt", data, function() {
-
- // TODO: Save locally if offline
- $(Khan).trigger("attemptSaved");
-
- }, function(xhr) {
- // Alert any listeners of the error before reload
- $(Exercises).trigger("attemptError");
-
- if (xhr && xhr.readyState == 0) {
- // This path gets called when there is a broken pipe during
- // page unload- browser navigating away during ajax request
- // See http://stackoverflow.com/questions/1370322/jquery-ajax-fires-error-callback-on-window-unload
- return;
- }
-
- // Error during submit. Disable the page and ask users to
- // reload in an attempt to get updated data.
-
- // Hide the page so users don't continue
- $("#problem-and-answer").css("visibility", "hidden");
-
- // Warn user about problem, encourage to reload page
- $(Exercises).trigger("warning",
- "This page is out of date. You need to <a href='" +
- _.escape(window.location.href) + "'>refresh</a>, but " +
- "don't worry, you haven't lost progress. If you think " +
- "this is a mistake, <a href='http://www.khanacademy.org/reportissue?type=Defect&issue_labels='>tell us</a>."
- );
-
- }, "attempt_hint_queue");
-
- if (assessmentMode) {
- $(Exercises).trigger("disableCheckAnswer");
- } else if (pass === true) {
- // Correct answer, so show the next question button.
- $("#check-answer-button").hide();
- $("#next-question-button")
- .removeAttr("disabled")
- .removeClass("buttonDisabled")
- .show()
- .focus();
- $("#positive-reinforcement").show();
- } else {
- // Wrong answer. Enable all the input elements
- $("#answercontent input").not("#hint")
- .removeAttr("disabled");
- }
-
- // Remember when the last action was
- lastAction = curTime;
-
- $(Exercises).trigger("checkAnswer", {
- pass: pass,
- // Determine if this attempt qualifies as fast completion
- fast: (typeof userExercise !== "undefined" && userExercise.secondsPerFastProblem >= data.time_taken)
- });
-
- return false;
- }
-
function initializeCalculator() {
var calculator = $(".calculator"),
history = calculator.children(".history"),
@@ -2174,85 +1940,15 @@ var Khan = (function() {
return false;
});
- $(Khan).on("gotoNextProblem", function(event) {
+ $(Khan).on("gotoNextProblem", function() {
input.val("");
history.children().not(inputRow).remove();
});
};
initializeCalculator();
- // Watch for when the next button is clicked
- $("#next-question-button").click(function(ev) {
- nextProblem(1);
- $(Exercises).trigger("gotoNextProblem");
-
- // Disable next question button until next time
- $(this)
- .attr("disabled", true)
- .addClass("buttonDisabled");
- });
-
- // If happy face is clicked, pass click on through.
- $("#positive-reinforcement").click(function() {
- $("#next-question-button").click();
- });
-
- // Watch for when the "Get a Hint" button is clicked
- $("#hint").click(function() {
-
- var hint = hints.shift();
-
- if (hint) {
- $(Exercises).trigger("hintUsed");
-
- hintsUsed += 1;
-
- var stepsLeft = hints.length + " step" + (hints.length === 1 ? "" : "s") + " left";
- $(this).val($(this).data("buttonText") || "I'd like another hint (" + stepsLeft + ")");
-
- var problem = $(hint).parent();
-
- // Append first so MathJax can sense the surrounding CSS context properly
- $(hint).appendTo("#hintsarea").runModules(problem);
-
- // Grow the scratchpad to cover the new hint
- Khan.scratchpad.resize();
-
- // Disable the get hint button & add final_answer class
- if (hints.length === 0) {
- $(hint).addClass("final_answer");
-
- $(Exercises).trigger("allHintsUsed");
-
- $(this).attr("disabled", true);
- }
- }
-
- var fProdReadOnly = !localMode && userExercise.readOnly;
- var fAnsweredCorrectly = $("#next-question-button").is(":visible");
- if (!fProdReadOnly && !fAnsweredCorrectly) {
- // Resets the streak and logs history for exercise viewer
- request(
- "problems/" + problemNum + "/hint",
- buildAttemptData(false, attempts, "hint", new Date().getTime()),
- // Don't do anything on success or failure, silently failing is ok here
- function() {},
- function() {},
- "attempt_hint_queue"
- );
- }
-
- });
-
- // On an exercise page, replace the "Report a Problem" link with a button
- // to be more clear that it won't replace the current page.
- $("<a>Report a Problem</a>")
- .attr("id", "report").addClass("simple-button green")
- .replaceAll($(".footer-links #report"));
-
$("#report").click(function(e) {
-
e.preventDefault();
var report = $("#issue").css("display") !== "none",
@@ -2274,17 +1970,14 @@ var Khan = (function() {
// Hide issue form.
$("#issue-cancel").click(function(e) {
-
e.preventDefault();
$("#issue").hide(500);
$("#issue-title, #issue-body").val("");
-
});
// Submit an issue.
$("#issue form input:submit").click(function(e) {
-
e.preventDefault();
// don't do anything if the user clicked a second time quickly
@@ -2296,14 +1989,14 @@ var Khan = (function() {
path = exerciseFile + "?seed=" +
problemSeed + "&problem=" + problemID,
pathlink = "[" + path + (exercise.data("name") !== exerciseId ? " (" + exercise.data("name") + ")" : "") + "](http://sandcastle.khanacademy.org/media/castles/Khan:master/exercises/" + path + "&debug)",
- historyLink = "[Answer timeline](" + "http://sandcastle.khanacademy.org/media/castles/Khan:master/exercises/" + path + "&debug&activity=" + encodeURIComponent(JSON.stringify(userActivityLog)).replace(/\)/g, "\\)") + ")",
+ historyLink = "[Answer timeline](" + "http://sandcastle.khanacademy.org/media/castles/Khan:master/exercises/" + path + "&debug&activity=" + encodeURIComponent(JSON.stringify(Exercises.userActivityLog)).replace(/\)/g, "\\)") + ")",
agent = navigator.userAgent,
mathjaxInfo = "MathJax is " + (typeof MathJax === "undefined" ? "NOT loaded" :
("loaded, " + (MathJax.isReady ? "" : "NOT ") + "ready, queue length: " + MathJax.Hub.queue.queue.length)),
userHash = "User hash: " + crc32(user),
sessionStorageInfo = (typeof sessionStorage === "undefined" || typeof sessionStorage.getItem === "undefined" ? "sessionStorage NOT enabled" : null),
warningInfo = $("#warning-bar-content").text(),
- parts = [$("#issue-body").val() || null, pathlink, historyLink, " " + JSON.stringify(guessLog), agent, sessionStorageInfo, mathjaxInfo, userHash, warningInfo],
+ parts = [$("#issue-body").val() || null, pathlink, historyLink, " " + JSON.stringify(Exercises.guessLog), agent, sessionStorageInfo, mathjaxInfo, userHash, warningInfo],
body = $.grep(parts, function(e) { return e != null; }).join("\n\n");
var mathjaxLoadFailures = $.map(MathJax.Ajax.loading, function(info, script) {
@@ -2380,19 +2073,14 @@ var Khan = (function() {
labels: labels
};
- // we try to post ot github without a cross-domain request, but if we're
- // just running the exercises locally, then we can't help it and need
- // to fall back to jsonp.
$.ajax({
-
- url: (localMode ? "http://www.khanacademy.org/" : "/") + "githubpost",
- type: localMode ? "GET" : "POST",
- data: localMode ? {json: JSON.stringify(dataObj)} :
- JSON.stringify(dataObj),
- contentType: localMode ? "application/x-www-form-urlencoded" : "application/json",
- dataType: localMode ? "jsonp" : "json",
+ url: "/githubpost",
+ type: "POST",
+ data: JSON.stringify(dataObj),
+ contentType: "application/json",
+ dataType: "json",
success: function(json) {
-
+ // TODO(alpert): Which is it?
var data = json.data || json;
// hide the form
@@ -2410,11 +2098,8 @@ var Khan = (function() {
// replace throbber with the cancel button
$("#issue-cancel").show();
$("#issue-throbber").hide();
-
},
- // note this won't actually work in local jsonp-mode
- error: function(json) {
-
+ error: function() {
// show status message
$("#issue-status").addClass("error")
.html(issueError).show();
@@ -2425,26 +2110,10 @@ var Khan = (function() {
// replace throbber with the cancel button
$("#issue-cancel").show();
$("#issue-throbber").hide();
-
}
});
});
- $("#warning-bar-close a").click(function(e) {
- e.preventDefault();
- $("#warning-bar").fadeOut("slow");
- });
-
- $("#scratchpad-show")
- .click(function(e) {
- e.preventDefault();
- Khan.scratchpad.toggle();
-
- if (user) {
- window.localStorage["scratchpad:" + user] = Khan.scratchpad.isVisible();
- }
- });
-
$("#answer_area").delegate("input.button, select", "keydown", function(e) {
// Don't want to go back to exercise dashboard; just do nothing on backspace
if (e.keyCode === 8) {
@@ -2464,22 +2133,27 @@ var Khan = (function() {
$(Khan)
.bind("updateUserExercise", function(ev, data) {
- // Any time we update userExercise, check if we're setting/switching usernames
+ // TODO(alpert): Why isn't this in setUserExercise?
+ // Any time we update userExercise, check if we're
+ // setting/switching usernames
if (data && data.userExercise) {
user = data.userExercise.user || user;
userCRC32 = user != null ? crc32(user) : null;
randomSeed = userCRC32 || randomSeed;
}
});
- // Register localMode-specific event handlers
- if (localMode) {
- // localMode automatically advances to the next problem --
- // integrated mode just listens and waits for renderNextProblem
- $(Khan).bind("gotoNextProblem", function() {
+ $(Khan).bind("gotoNextProblem", function() {
+ if (localMode) {
+ // Automatically advance to the next problem
+ nextProblem(1);
renderNextProblem();
- });
- }
+ } else {
+ // Just listen for the readyForNextProblem event, which will
+ // include an updated userExercise (and thus an updated problem
+ // number)
+ }
+ });
Khan.relatedVideos.hookup();
@@ -2505,6 +2179,41 @@ var Khan = (function() {
})
.bind("cleanupProblem", function() {
$("#workarea, #hintsarea").runModules(problem, "Cleanup");
+ })
+ .bind("showHint", function() {
+ var hint = hints.shift();
+ if (!hint) {
+ // :(
+ return;
+ }
+
+ var problem = $(hint).parent();
+
+ // Append first so MathJax can sense the surrounding CSS context properly
+ $(hint).appendTo("#hintsarea").runModules(problem);
+
+ if (hints.length === 0) {
+ $(hint).addClass("final_answer");
+ }
+
+ $(Exercises).trigger("hintUsed");
+ })
+ .bind("refocusSolutionInput", function() {
+ // Refocus text field so user can type a new answer
+ if (lastFocusedSolutionInput != null) {
+ setTimeout(function() {
+ var focusInput = $(lastFocusedSolutionInput);
+
+ if (!focusInput.is(":disabled")) {
+ // focus should always work; hopefully select
+ // will work for text fields
+ focusInput.focus();
+ if (focusInput.is("input:text")) {
+ focusInput.select();
+ }
+ }
+ }, 1);
+ }
});
}
@@ -2541,12 +2250,7 @@ var Khan = (function() {
setProblemNum(problemNum + num);
}
- function prevProblem(num) {
- nextProblem(-num);
- }
-
function setUserExercise(data) {
-
userExercise = data;
if (data && data.exercise) {
@@ -2571,109 +2275,6 @@ var Khan = (function() {
}
}
- function request(method, data, fn, fnError, queue) {
- if (localMode) {
- // Pretend we have success
- if ($.isFunction(fn)) {
- fn();
- }
-
- return;
- }
-
- var xhrFields = {};
- if (typeof XMLHTTPRequest !== "undefined") {
- // If we have native XMLHTTPRequest support,
- // make sure cookies are passed along.
- xhrFields["withCredentials"] = true;
- }
-
- // TODO(david): Try harder to decouple Exercises outta this file
- var apiBaseUrl = (assessmentMode ?
- "api/v1/user/assessment/exercises" : "api/v1/user/exercises");
-
- var request = {
- // Do a request to the server API
- url: server + "/" + apiBaseUrl + "/" + exerciseId + "/" + method,
- type: "POST",
- data: data,
- dataType: "json",
- xhrFields: xhrFields,
-
- // Backup the response locally, for later use
- success: function(data) {
-
- // Tell any listeners that khan-exercises has new
- // userExercise data
- $(Exercises).trigger("updateUserExercise", {
- userExercise: data,
- source: "serverResponse"
- });
-
- if ($.isFunction(fn)) {
- fn(data);
- }
- },
-
- complete: function() {
- $(Exercises).trigger("apiRequestEnded");
- },
-
- // Handle error edge case
- error: function(xhr) {
- // Execute passed error function first in case it wants
- // different behavior depending upon the length of the request
- // queue
- if ($.isFunction(fnError)) {
- fnError(xhr);
- }
-
- // Clear the queue so we don't spit out a bunch of
- // queued up requests after the error
- if (queue && requestQueue[queue]) {
- requestQueue[queue].clearQueue();
- }
- }
- };
-
- // Send request and return a jqXHR (which implements the Promise interface)
- function sendRequest() {
- // Do request using OAuth, if available
- if (typeof oauth !== "undefined" && $.oauth) {
- return $.oauth($.extend({}, oauth, request));
- }
-
- return $.ajax(request);
- }
-
- // We may want to queue up the requests so that they're sent serially.
- //
- // We currently do this for problem attempts and hints to avoid a race
- // condition:
- // 1) request A fetches UserExercise X
- // 2) request B also fetches X
- // 3) A finishes updating X and saves as A(X)
- // 4) now B finishes updating X and overwrites A(X) with B(X)
- // ...when what we actually wanted saved is B(A(X)). We should really fix it
- // on the server by running the hint and attempt requests in transactions.
- if (queue != null) {
- // Create an empty jQuery object to use as a queue holder, if needed.
- requestQueue[queue] = requestQueue[queue] || jQuery({});
-
- // Queue up sending the request to run when old requests have completed.
- requestQueue[queue].queue(function(next) {
- sendRequest().always(next);
- });
- } else {
- sendRequest();
- }
-
- // Trigger an apiRequestStarted event here, and not in the inner sendRequest()
- // function, because listeners should know an API request is waiting as
- // soon as it gets queued up.
- $(Exercises).trigger("apiRequestStarted");
- }
-
/**
* Load an exercise and return a promise that is resolved when an exercise
* is loaded
@@ -2865,6 +2466,7 @@ var Khan = (function() {
prepareSite();
+ exercises = exercises.add($("div.exercise").detach());
var problems = exercises.children(".problems").children();
// Don't make the problem bag when a specific problem is specified
@@ -2877,7 +2479,7 @@ var Khan = (function() {
$("#positive-reinforcement").hide();
// Generate the initial problem when dependencies are done being loaded
- var answerType = makeProblem();
+ makeProblem();
}
return Khan;

0 comments on commit cbbd22d

Please sign in to comment.