Skip to content

Commit

Permalink
Basic support for multiple users (simultaneous "sessions"); and added…
Browse files Browse the repository at this point in the history
… the DELETE button for quiz items.
  • Loading branch information
oranda committed May 12, 2015
1 parent bb68113 commit 0602188
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 51 deletions.
7 changes: 7 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
== 0.2 May 13 2015

* Multiple simultaneous users now supported.

* Added DELETE item button and functionality.


== 0.1 May 12 2015

* Multiple-choice functionality (QuizScreen). Only works for a single user and a single quiz group.
43 changes: 28 additions & 15 deletions app/js/src/main/scala/com/oranda/libanius/scalajs/QuizScreen.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import scala.scalajs.js.annotation.JSExport
@JSExport
object QuizScreen {

case class State(currentQuizItem: Option[QuizItemReact] = None,
case class State(userToken: String, currentQuizItem: Option[QuizItemReact] = None,
prevQuizItem: Option[QuizItemReact] = None, scoreText: String = "",
chosen: Option[String] = None, status: String = "") {

Expand All @@ -39,27 +39,36 @@ object QuizScreen {
}

class Backend(scope: BackendScope[Unit, State]) {
def submitResponse(choice: String, curQuizItem: QuizItemReact): Unit = {
def submitResponse(choice: String, curQuizItem: QuizItemReact) {
scope.modState(_.copy(chosen = Some(choice)))
val url = "/processUserResponse"
val response = QuizItemResponse.construct(curQuizItem, choice)
val response = QuizItemAnswer.construct(scope.state.userToken, curQuizItem, choice)
val data = upickle.write(response)

val sleepMillis: Double = if (response.isCorrect) 200 else 1000
Ajax.post(url, data).foreach { xhr =>
setTimeout(sleepMillis) { updateStateFromAjaxCall(xhr.responseText, scope) }
}
}

def removeCurrentWordAndShowNextItem(curQuizItem: QuizItemReact) {
val url = "/removeCurrentWordAndShowNextItem"
val response = QuizItemAnswer.construct(scope.state.userToken, curQuizItem, "")
val data = upickle.write(response)
Ajax.post(url, data).foreach { xhr =>
updateStateFromAjaxCall(xhr.responseText, scope)
}
}
}

def updateStateFromAjaxCall(responseText: String, scope: BackendScope[Unit, State]): Unit = {

val curQuizItem = scope.state.currentQuizItem
upickle.read[DataToClient](responseText) match {
case quizItemData: DataToClient =>
val newQuizItem = quizItemData.quizItemReact
// Set new quiz item and switch curQuizItem into the prevQuizItem position
scope.setState(State(newQuizItem, curQuizItem, quizItemData.scoreText))
scope.setState(State(scope.state.userToken, newQuizItem, curQuizItem,
quizItemData.scoreText))
}
}

Expand All @@ -68,11 +77,6 @@ object QuizScreen {
"Score: " + scoreText))
.build

val Header = ReactComponentB[String]("Header")
.render(scoreText =>
<.span(^.id := "header-wrapper", ScoreText(scoreText)))
.build

case class Question(promptWord: String, responseType: String, numCorrectResponsesInARow: Int)

val QuestionArea = ReactComponentB[Question]("Question")
Expand Down Expand Up @@ -129,13 +133,22 @@ object QuizScreen {
}
}

private[this] def generateUserToken =
System.currentTimeMillis + "" + scala.util.Random.nextInt(1000)

val QuizScreen = ReactComponentB[Unit]("QuizScreen")
.initialState(State())
.initialState(State(generateUserToken))
.backend(new Backend(_))
.render((_, state, backend) => state.currentQuizItem match {
// Only show the page if there is a quiz item
case Some(currentQuizItem: QuizItemReact) =>
<.div(Header(state.scoreText),
<.div(
<.span(^.id := "header-wrapper", ScoreText(state.scoreText),
<.span(^.className := "alignright",
<.button(^.id := "delete-button",
^.onClick --> backend.removeCurrentWordAndShowNextItem(currentQuizItem),
"DELETE WORD"))
),
QuestionArea(Question(currentQuizItem.prompt,
currentQuizItem.responseType,
currentQuizItem.numCorrectResponsesInARow)),
Expand All @@ -157,12 +170,12 @@ object QuizScreen {
})
.componentDidMount(scope => {
// Make the Ajax call for the first quiz item
val url = "/findNextQuizItem"
val url = "/findFirstQuizItem?userToken=" + scope.state.userToken
Ajax.get(url).foreach { xhr =>
val quizData = upickle.read[DataToClient](xhr.responseText)
val newQuizItem = quizData.quizItemReact
// Set new quiz item and switch curQuizItem into the prevQuizItem position
scope.setState(State(newQuizItem, None, quizData.scoreText))
// Set new quiz item
scope.setState(State(scope.state.userToken, newQuizItem, None, quizData.scoreText))
}}
)
.buildU
Expand Down
102 changes: 76 additions & 26 deletions app/jvm/src/main/scala/com/oranda/libanius/server/QuizService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,58 +31,108 @@ import com.oranda.libanius.scalajs._
import QuizItemSource._
import modelComponentsAsQuizItemSources._

import scala.concurrent._
import scala.concurrent.ExecutionContext.Implicits.global

import scala.collection.immutable.ListMap

object QuizService extends AppDependencyAccess {

val config = ConfigFactory.load().getConfig("libanius")
private[this] val config = ConfigFactory.load().getConfig("libanius")

private[this] val promptType = config.getString("promptType")
private[this] val responseType = config.getString("responseType")

private[this] val qgh = dataStore.findQuizGroupHeader(promptType, responseType, WordMapping)
private[this] val qghReverse = dataStore.findQuizGroupHeader(responseType, promptType,
WordMapping)

// Map of user tokens to quizzes (in lieu of a proper database)
private[this] var userQuizMap: Map[String, Quiz] = ListMap()


val promptType = config.getString("promptType")
val responseType = config.getString("responseType")
def findFirstQuizItem(userToken: String) = {
val quiz = loadQuiz
userQuizMap += (userToken -> quiz)
saveQuiz(userToken)
DataToClient(findQuizItem(quiz), scoreText(quiz))
}

val qgh = dataStore.findQuizGroupHeader(promptType, responseType, WordMapping)
val qghReverse = dataStore.findQuizGroupHeader(responseType, promptType, WordMapping)
def processUserResponse(qia: QuizItemAnswer): DataToClient = {
val quiz = getQuiz(qia.userToken)
for {
qgh <- dataStore.findQuizGroupHeader(qia.promptType, qia.responseType)
quizItem <- quiz.findQuizItem(qgh, qia.prompt, qia.correctResponse)
} yield Util.stopwatch(updateWithUserResponse(qia.userToken, qia.isCorrect, qgh, quizItem))

// Persistent (immutable) data structure used in this single-user local web application.
var quiz: Quiz = loadQuiz
saveQuiz(qia.userToken)
findNextQuizItem(qia.userToken)
}

def removeCurrentWordAndShowNextItem(qia: QuizItemAnswer): DataToClient = {
qgh.foreach(removeWord(qia.userToken, _, qia.prompt, qia.correctResponse))
saveQuiz(qia.userToken)
findNextQuizItem(qia.userToken)
}

private def loadQuiz: Quiz = {
private[this] def loadQuiz: Quiz = {
val quizGroupHeaders = Seq(qgh, qghReverse).flatten
Quiz(quizGroupHeaders.map(qgh => (qgh, dataStore.loadQuizGroup(qgh))).toMap)
Quiz(quizGroupHeaders.map(qgh => (qgh, dataStore.initQuizGroup(qgh))).toMap)
}

private def findPresentableQuizItem: Option[QuizItemViewWithChoices] =
private[this] def findPresentableQuizItem(quiz: Quiz): Option[QuizItemViewWithChoices] =
produceQuizItem(quiz, NoParams())

def findNextQuizItem: DataToClient = {
val quizItemReact = findPresentableQuizItem.map { qiv =>
val promptResponseMap = makePromptResponseMap(qiv.allChoices, qiv.quizGroupHeader)
private[this] def findNextQuizItem(userToken: String): DataToClient = {
val quiz = getQuiz(userToken)
DataToClient(findQuizItem(quiz), scoreText(quiz))
}

private[this] def findQuizItem(quiz: Quiz): Option[QuizItemReact] =
findPresentableQuizItem(quiz).map { qiv =>
val promptResponseMap = makePromptResponseMap(quiz, qiv.allChoices, qiv.quizGroupHeader)
QuizItemReact.construct(qiv, promptResponseMap)
}
DataToClient(quizItemReact, scoreText)
}

def scoreText: String = StringUtil.formatScore(quiz.scoreSoFar)
private[this] def scoreText(quiz: Quiz): String = StringUtil.formatScore(quiz.scoreSoFar)

def processUserResponse(qir: QuizItemResponse): DataToClient = {
for {
qgh <- dataStore.findQuizGroupHeader(qir.promptType, qir.responseType)
quizItem <- quiz.findQuizItem(qgh, qir.prompt, qir.correctResponse)
} yield Util.stopwatch(quiz = quiz.updateWithUserResponse(qir.isCorrect, qgh, quizItem))
private[this] def getQuiz(userToken: String) = userQuizMap.getOrElse(userToken, loadQuiz)

findNextQuizItem
private[this] def updateWithUserResponse(userToken: String, isCorrect: Boolean,
qgh: QuizGroupHeader, quizItem: QuizItem) {
val quiz = getQuiz(userToken)
val updatedQuiz = quiz.updateWithUserResponse(isCorrect, qgh, quizItem)
userQuizMap += (userToken -> updatedQuiz)
}

private def makePromptResponseMap(choices: Seq[String], quizGroupHeader: QuizGroupHeader):
Seq[(String, String)] =
choices.map(promptToResponses(_, quizGroupHeader))
private[this] def makePromptResponseMap(quiz: Quiz, choices: Seq[String],
quizGroupHeader: QuizGroupHeader): Seq[(String, String)] =
choices.map(promptToResponses(quiz, _, quizGroupHeader))

private def promptToResponses(choice: String, quizGroupHeader: QuizGroupHeader):
private[this] def promptToResponses(quiz: Quiz, choice: String, quizGroupHeader: QuizGroupHeader):
Tuple2[String, String] = {

val values = quiz.findPromptsFor(choice, quizGroupHeader) match {
case Nil => quiz.findResponsesFor(choice, quizGroupHeader.reverse)
case v => v
}

(choice, values.mkString(", "))
}

private[this] def removeWord(userToken: String, qgh: QuizGroupHeader, prompt: String,
correctResponse: String) {

val quiz = getQuiz(userToken)
val quizItem = QuizItem(prompt, correctResponse)
val (updatedQuiz, wasRemoved) = quiz.removeQuizItem(quizItem, qgh)
userQuizMap += (userToken -> updatedQuiz)
if (wasRemoved) l.log("Deleted quiz item " + quizItem)
else l.logError("Failed to remove " + quizItem)
}

private[this] def saveQuiz(userToken: String) {
val quiz = getQuiz(userToken)
Future { dataStore.saveQuiz(quiz, conf.filesDir, userToken) }
}
}
20 changes: 15 additions & 5 deletions app/jvm/src/main/scala/com/oranda/libanius/server/Server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ object Server extends SimpleRoutingApp with AppDependencyAccess {
)
}
} ~
path("findNextQuizItem") {
complete {
upickle.write(QuizService.findNextQuizItem)
path("findFirstQuizItem") {
parameter("userToken") { userToken =>
complete {
upickle.write(QuizService.findFirstQuizItem(userToken))
}
}
} ~
// serve other requests directly from the resource directory
Expand All @@ -53,8 +55,16 @@ object Server extends SimpleRoutingApp with AppDependencyAccess {
path("processUserResponse") {
extract(_.request.entity.asString) { e =>
complete {
val quizItemResponse = upickle.read[QuizItemResponse](e)
upickle.write(QuizService.processUserResponse(quizItemResponse))
val quizItemAnswer = upickle.read[QuizItemAnswer](e)
upickle.write(QuizService.processUserResponse(quizItemAnswer))
}
}
} ~
path("removeCurrentWordAndShowNextItem") {
extract(_.request.entity.asString) { e =>
complete {
val quizItemAnswer = upickle.read[QuizItemAnswer](e)
upickle.write(QuizService.removeCurrentWordAndShowNextItem(quizItemAnswer))
}
}
}
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,19 @@ object QuizItemReact {
qi.numCorrectResponsesInARow, promptResponseMap)
}

case class QuizItemResponse(prompt: String, correctResponse: String,
promptType: String, responseType: String, response: String) {
abstract class RequestToServer(val userToken: String)

case class QuizItemAnswer(override val userToken: String, prompt: String, correctResponse: String,
promptType: String, responseType: String, response: String)
extends RequestToServer(userToken) {

def isCorrect = correctResponse == response
}

object QuizItemResponse {
object QuizItemAnswer {

// Should be apply, but upickle complains.
def construct(qi: QuizItemReact, choice: String): QuizItemResponse =
QuizItemResponse(qi.prompt, qi.correctResponse, qi.promptType, qi.responseType, choice)
def construct(userToken: String, qi: QuizItemReact, choice: String): QuizItemAnswer =
QuizItemAnswer(userToken, qi.prompt, qi.correctResponse, qi.promptType,
qi.responseType, choice)
}

0 comments on commit 0602188

Please sign in to comment.