Jelajahi Sumber

Added recipie view screen + view/edit buttons.

Minor bug fixes and refactoring.
Thomas Flucke 2 tahun lalu
induk
melakukan
ea60985ffc

+ 4 - 2
build.sbt

@@ -14,7 +14,7 @@ def commonSettings = Seq(
 lazy val server: Project = (project in file("server"))
   .settings(commonSettings)
   .settings(
-    scalaJSProjects := Seq(client),
+    scalaJSProjects := Seq(fdcJs, sharedJs, client),
     Assets / pipelineStages := Seq(scalaJSPipeline),
     Compile / compile := ((Compile / compile) dependsOn scalaJSPipeline).value,
     Test / javaOptions += "-Dconfig.file=conf/testing.conf",
@@ -67,7 +67,8 @@ lazy val client = (project in file("webClient"))
       if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L))
         Seq("-Ymacro-annotations")
       else Nil
-    }
+    },
+    Compile / fastLinkJS / scalaJSLinkerConfig ~= (_.withSourceMap(true))
   )
   .enablePlugins(ScalaJSPlugin, ScalaJSWeb)
   .dependsOn(sharedJs)
@@ -103,6 +104,7 @@ lazy val fdc = crossProject(JSPlatform, JVMPlatform)
     libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2",
     libraryDependencies += "com.tflucke"       %%% "rest-rpc"  % "0.3.2"
   )
+  .jsConfigure(_ enablePlugins ScalaJSWeb)
 lazy val fdcJs = fdc.js
 lazy val fdcJvm = fdc.jvm
 

+ 51 - 7
server/app/com/weEat/controllers/FoodController.scala

@@ -13,6 +13,7 @@ import scala.util.{Success,Failure}
 import com.weEat.models.Authorization
 import scalaoauth2.provider.{AuthInfoRequest,OAuth2ProviderActionBuilders}
 import com.weEat.services.OAuth2Service
+import org.mongodb.scala.model.Filters._
 
 @Singleton
 class FoodController @Inject()(
@@ -27,11 +28,12 @@ class FoodController @Inject()(
 
   def get(id: String) = Action.async
   { implicit request: Request[AnyContent] =>
-    withCollection(FoodNodeCollection) {collection =>
-      collection.find(equal("id", id))
+    withCollection(FoodNodeCollection) { (collection) =>
+      collection.find(equal("_id", new ObjectId(id)))
         .first()
         .toFuture()
         .transform({
+          case Success(null) => Success(NotFound(id))
           case Success(x) => Success(Ok(Json.toJson(x)))
           case Failure(x) => throw x
         })
@@ -40,7 +42,7 @@ class FoodController @Inject()(
 
   def all() = Action.async
   { implicit request: Request[AnyContent] =>
-    withCollection(FoodNodeCollection) {collection =>
+    withCollection(FoodNodeCollection) { (collection) =>
       collection.find()
         .toFuture()
         .transform({
@@ -52,7 +54,7 @@ class FoodController @Inject()(
 
   def query(q: String) = Action.async
   { implicit request: Request[AnyContent] =>
-    withCollection(FoodNodeCollection) {collection =>
+    withCollection(FoodNodeCollection) { (collection) =>
       collection.find(regex("name", q, "i"))
         .toFuture()
         .transform({
@@ -125,13 +127,13 @@ class FoodController @Inject()(
     try {
       val food = request.body.as[FoodNode].withId(
         new ObjectId,
-        uid.flatMap({ id =>
+        uid.flatMap({ (id) =>
           if (request.authInfo.user.hasAdminPermissions) Some(new ObjectId(id))
           else None
         }).getOrElse(request.authInfo.user.userId)
       )
-      withCollection(FoodNodeCollection) {collection =>
-        collection.insertOne(food).map({ res =>
+      withCollection(FoodNodeCollection) { (collection) =>
+        collection.insertOne(food).map({ (res) =>
           val id = res.getInsertedId().asObjectId().getValue()
           Ok(Json.toJson(food.withId(id)))
         }).head()
@@ -143,4 +145,46 @@ class FoodController @Inject()(
       )
     }
   }
+
+  def update(id: String, uid: Option[String]) =
+    AuthorizedAction[Authorization](oauth)
+      .async(parse.json)
+  { implicit request: AuthInfoRequest[JsValue, Authorization] =>
+    try {
+      val refFood = if (request.authInfo.user.hasAdminPermissions)
+        request.body.as[FoodNode]
+      else
+        request.body.as[FoodNode]
+          .withId(new ObjectId(id), request.authInfo.user.userId)
+
+      val food = refFood.withId(
+        new ObjectId(id),
+        uid.flatMap({ (id) =>
+          if (request.authInfo.user.hasAdminPermissions) Some(new ObjectId(id))
+          else None
+          // tflucke@[2023-10-07] Should this query for the current uid instead?
+        }).getOrElse(request.authInfo.user.userId)
+      )
+      withCollection(FoodNodeCollection) { (collection) =>
+        collection.replaceOne(refFood match {
+          case fni: FoodNodeId => and(
+            equal("_id", new ObjectId(id)),
+            equal("uid", fni.uid)
+          )
+          case _ => equal("_id", new ObjectId(id))
+        }, food)
+          .map({ (res) => if (res.getModifiedCount > 0)
+              Ok(Json.toJson(food))
+            else
+              NotFound(s"User ${request.authInfo.user.userId} does not have a food node $id")
+          })
+          .head()
+      }.flatten
+    }
+    catch {
+      case _: JsResultException => Future.successful(
+        BadRequest(s"Could not parse json into a Food node.")
+      )
+    }
+  }
 }

+ 5 - 0
server/conf/routes

@@ -40,6 +40,11 @@ GET   /v1/user/self/name/ com.weEat.controllers.UserController.getName()
 # type: com.weEat.shared.models.FoodNodeId
 PUT  /v1/food/     com.weEat.controllers.FoodController.add(uid: Option[String] ?= None)
 
+# Shared Route
+# body: com.weEat.shared.models.FoodNode
+# type: com.weEat.shared.models.FoodNodeId
+POST /v1/food/:id  com.weEat.controllers.FoodController.update(id: String, uid: Option[String] ?= None)
+
 # Shared Route
 # type: Seq[com.weEat.shared.models.FoodNodeId]
 GET  /v1/food/     com.weEat.controllers.FoodController.query(q: String)

+ 1 - 0
shared/shared/src/main/scala/com/weEat/shared/OAuthManager.scala

@@ -108,6 +108,7 @@ object OAuthManager {
     c: ExecutionContext
   ): Future[Unit] = if (isExpired) refreshToken() else Future.unit
 
+  def username: Option[String] = SecureStorage.get("username")
 
   private var observers: Seq[() => Unit] = Nil
   def addObserver(observer: () => Unit) = observers = observer +: observers

+ 71 - 8
shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala

@@ -33,7 +33,7 @@ sealed trait FoodNodeId extends FoodNode {
 
   def withId(id: Identifier): FoodNodeId = withId(id, uid)
 
-  lazy val user = UserController.get(uid.toString)()
+  lazy val user: Future[User] = UserController.get(uid.toString)()
 }
 
 case class Ingredient(
@@ -252,6 +252,50 @@ object USDANodeNoId {
 
   import gov.usda.nal.fdc.models._
 
+  private case class Measure(
+    portion: FoodPortion,
+    override val unit: com.weEat.shared.models.MeasureUnit,
+    override val raw: String
+  ) extends ParsedMeasure(unit, raw) {
+    def conversion: Float = (portion.gramWeight/unit.conversionRatio).toFloat
+  }
+
+  private val undefinedMeasureUnitId = 9999
+
+  private def portionStr(fp: FoodPortion) =
+    fp.portionDescription.getOrElse(fp.modifier.getOrElse("Unknown"))
+
+  private val measureMatchThreshold = 0.01
+
+  /* Procedure to determine default:
+   * 1. Check if there is some value already:
+   * | yes:
+   * |- 2. Find the measure which most closely results in that value
+   * |  3. If closest match is not within the threshold of the value, use custom
+   * | no:
+   * |- 2. if defaultOverNone:
+   * |  | yes:
+   * |  |- 3. Use (portion, measure) which has the best matching between the two
+   * |  | no:
+   * |  |- 3. Use none
+   */
+  private def getDefaultMeasure(
+    measures: Seq[Measure],
+    typ: UnitType,
+    value: Option[Float]
+  ): Option[Measure] = (value match {
+    case Some(dV) => measures.filter(_.unit.typ == typ).minByOption({ (meas) =>
+      val diff = (meas.conversion - dV).abs
+      Option.when(diff < measureMatchThreshold)(diff)
+    })
+    case None => com.weEat.shared.models.MeasureUnit
+        .closestPair(measures.filter(_.unit.typ == typ))
+  })
+
+  private def portionToMeasure(fp: FoodPortion, str: String): com.weEat.shared.models.MeasureUnit =
+    if (fp.measureUnit.id != undefinedMeasureUnitId) Count
+    else com.weEat.shared.models.MeasureUnit.guessUnit(str).getOrElse(Count)
+
   def fromFoodItem(usda: FoodItem) = usda match {
     case AbridgedFoodItem(id, _, desc, nutr, _, _, _, _, _) => USDANodeNoId(
       desc,
@@ -264,11 +308,13 @@ object USDANodeNoId {
     case BrandedFoodItem(id, _, desc, _, _, _, _, _, _, _, _, servSize, servUnit,
       _, nutr, _, _, _) => USDANodeNoId(
       desc,
-      id,
-      None,
-      None,
-      nutr.find(_.nutrient.id == kcalNutrientId).flatMap(_.amount).getOrElse(0.0f),
-      nutr.flatMap(x => x.amount.map(amt => (x.nutrient.number, amt))).toMap
+        id,
+        None,
+        None,
+        nutr.find(_.nutrient.id == kcalNutrientId)
+          .flatMap(_.amount)
+          .getOrElse(0.0f),
+        nutr.flatMap(x => x.amount.map(amt => (x.nutrient.number, amt))).toMap
     )
     case FoundationFoodItem(id, _, desc, _, _, _, _, _, _, _, _, nutr, port, _,
       _) => USDANodeNoId(
@@ -294,7 +340,23 @@ object USDANodeNoId {
       ).flatten.toMap
     )
     case SurveyFoodItem(id, _, desc, _, _, _, _, _, _, port, _, _) => ???
-    case SRLegacyFoodItem(_, _, desc, _, _, _, _, _, _, nutr, _, _, _, port, _) => ???
+    case SRLegacyFoodItem(id, _, dsc, _, _, _, _, _, _, nutr, _, _, _, port, _) =>
+      USDANodeNoId(
+        dsc,
+        id,
+        getDefaultMeasure(port.map { (port) =>
+          val str = portionStr(port)
+          Measure(port, portionToMeasure(port, str), str)
+        }, VOLUME, None).map(_.conversion),
+        getDefaultMeasure(port.map { (port) =>
+          val str = portionStr(port)
+          Measure(port, portionToMeasure(port, str), str)
+        }, MASS, None).map(_.conversion),
+        nutr.find(_.nutrient.id == kcalNutrientId)
+          .flatMap(_.amount)
+          .getOrElse(0.0f),
+        nutr.flatMap(x => x.amount.map(amt => (x.nutrient.number, amt))).toMap
+      )
     case SampleFoodItem(id, _, desc, _, _, _) => ???
   }
 
@@ -326,10 +388,11 @@ object FoodNodeId {
 
   import NodeType._
 
-  implicit val requestWrites = Writes[FoodNodeId](requ =>
+  implicit val requestWrites = Writes[FoodNodeId]((requ) =>
     (requ match {
       case node: USDANodeId => Json.toJson(node)
       case node: RecipeNodeId => Json.toJson(node)
+      case node => println(node);???
     }).asInstanceOf[JsObject] + (("node_type", Json.toJson(requ.nodeType)))
   )
 

+ 8 - 9
webClient/src/main/scala/com/weEat/Main.scala

@@ -1,6 +1,7 @@
 package com.weEat
 
 import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
 
 import com.tflucke.webroutes.Headers
 import com.weEat.modules.Navbar
@@ -33,17 +34,15 @@ object Main {
 
     renderOnDomContentLoaded(document.getElementById("navbarNav"), navbar.render)
     renderOnDomContentLoaded(document.getElementById("content"), div(
-      child <-- View.splitter.signal
+      child <-- View.splitter.signal,
+      OAuthSignal.username.isDefined --> { (isLoggedIn) =>
+        document.getElementById("login-btns").asInstanceOf[HTMLUListElement]
+          .style.display = if (isLoggedIn) "none" else ""
+        document.getElementById("logout-btns").asInstanceOf[HTMLUListElement]
+          .style.display = if (isLoggedIn) "" else "none"
+      }
     ))
 
-    def _isLoggedIn = !OAuthManager.isExpired
-    OAuthManager.addObserver({ () =>
-      document.getElementById("login-btns").asInstanceOf[HTMLUListElement]
-        .style.display = if (_isLoggedIn) "none" else ""
-      document.getElementById("logout-btns").asInstanceOf[HTMLUListElement]
-        .style.display = if (_isLoggedIn) "" else "none"
-    })
-
 
     windowEvents(_.onLoad).foreach { (_) =>
       document.getElementById("btn-login").asInstanceOf[HTMLAnchorElement]

+ 20 - 0
webClient/src/main/scala/com/weEat/OAuthSignal.scala

@@ -0,0 +1,20 @@
+package com.weEat
+
+import com.raquo.laminar.api.L._
+import com.weEat.shared.OAuthManager
+import com.weEat.shared.models.IdentifierHelper._
+
+object OAuthSignal {
+  val _username = Var[Option[String]](None)
+  val username = _username.signal
+
+  val _permissions = Var[Set[String]](Set())
+  val permissions = _permissions.signal
+
+  private def _OauthUpdated() = {
+    _username.set(OAuthManager.username)
+    _permissions.set(OAuthManager.currentScope)
+  }
+
+  OAuthManager.addObserver(_OauthUpdated _)
+}

+ 374 - 0
webClient/src/main/scala/com/weEat/models/RecipeVar.scala

@@ -0,0 +1,374 @@
+package com.weEat.models
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+
+import com.weEat.modules.{Overlay,Typeahead}
+import com.weEat.shared.models.UnitType._
+import com.weEat.shared.models._
+import gov.usda.nal.fdc.models.DataType._
+import com.tflucke.typeahead._
+import com.tflucke.typeahead.Dataset.Templates
+import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
+import com.weEat.controllers.{FoodController,USDAController}
+import scala.util.{Try,Success,Failure}
+import org.scalajs.dom.document
+import org.scalajs.dom.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
+import scala.concurrent.Future
+
+case class RecipeVar(recipe: Option[RecipeNode])
+    extends VarRepresentationOf[RecipeNodeNoId] {
+
+  implicit val ec = com.weEat.shared.ctx
+
+  private def _defaultRecipe = recipe.getOrElse(RecipeNodeNoId(
+    "New Recipe",
+    400.0f,
+    100.0f,
+    defaultUnitType = UnitType.MASS,
+    Seq(),
+    Seq(),
+    None,
+    None
+  ))
+
+  private val _nameIn = input(idAttr := "name",
+    cls := "form-control",
+    required := true,
+    defaultValue := _defaultRecipe.name
+  )
+  val name: Signal[String] = _nameIn.value
+
+  private val _servingsIn = input(idAttr := "serv",
+    typ := "number",
+    cls := "form-control",
+    defaultValue := (_defaultRecipe.stdQties / _defaultRecipe.stdQtiesPServing)
+      .toString,
+    stepAttr := "0.1",
+    required := true
+  )
+  private val _servings = _servingsIn.value.map(_.toFloat)
+
+  // private val _discreetIn = input(idAttr := "discreet",
+  //   typ := "checkbox",
+  //   cls := "form-check-input",
+  //   defaultChecked := (_defaultRecipe.defaultUnitType match {
+  //     case NUMBER  => true
+  //     case MASS   => _defaultRecipe.massPerUnit.isDefined
+  //     case VOLUME => _defaultRecipe.massPerUnit
+  //         .zip(_defaultRecipe.density)
+  //         .isDefined
+  //   })
+  // )
+  // private val _discreet = _discreetIn.checked
+
+  // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
+  private val _massPerUnitIn = input(idAttr := "massP",
+    cls := "form-control",
+    typ := "number",
+    minAttr := "1",
+    stepAttr := "0.1",
+    defaultValue := _defaultRecipe.massPerUnit.map(_.toString).getOrElse("")
+  )
+  val massPerUnit = _massPerUnitIn.value.transitions.map({
+    case (None, "") => "100"
+    case (Some(""), "") => "100"
+    case (Some(_), str) => str
+  }).map(_.toFloatOption)
+
+  private val _volumeUnit = Var[MeasureUnit](Milliliter)
+  private val _volumeUnitIn = select(cls := "custom-select",
+    MeasureUnit.units.zipWithIndex
+      .filter({ case (u, _) => u.typ == VOLUME })
+      .map({ case (unit, idx) =>
+        // TODO: default selected dynamic
+        option(
+          value := idx.toString,
+          selected := unit.abr == Milliliter.abr,
+          unit.abr
+        )
+      }),
+    onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> _volumeUnit
+  )
+
+  // TODO: Try autopopulate ingredients.averge(_.density)
+  private val _volumePerServIn = input(idAttr := "volP",
+    cls := "form-control",
+    typ := "number",
+    minAttr := "0",
+    stepAttr := "0.1",
+    defaultValue := (_defaultRecipe.defaultUnitType match {
+      case VOLUME =>
+        (_defaultRecipe.stdQtiesPServing * UnitType.standardQuanity(VOLUME))
+          .toString
+      case MASS   => _defaultRecipe.density
+          .map((d) => (_defaultRecipe.stdQtiesPServing / d * UnitType.standardQuanity(MASS)).toString).getOrElse("")
+      case NUMBER  => _defaultRecipe.massPerUnit.zip(_defaultRecipe.density)
+          .map {
+            case (mass, density) => (mass / density * UnitType.standardQuanity(NUMBER)).toString
+          } getOrElse("")
+    })
+  )
+  private val _volumePerServ = _volumePerServIn.value.map(_.toFloatOption)
+    .combineWithFn(_volumeUnit) {
+      case (Some(num), unit) => Some((num * unit.conversionRatio).toFloat)
+      case (None, _) => None
+    }
+
+
+  private val internalFoodDS = Dataset(
+    { _ => Nil},
+    Some({ (str: String) => FoodController.query(str.toLowerCase)() }),
+    display = { (food: FoodNodeId) => food.name }
+  )
+
+  private val usdaFoodDS = Dataset(
+    { _ => Nil},
+    Some({ str: String =>
+      USDAController.getFoodsSearch(str, Seq(
+        Foundation, Survey, SRLegacy
+      ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
+    }),
+    templates = Some(Templates({(x: USDANodeNoId) => x.name}).copy(
+      header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
+        val html = document.createElement("div").asInstanceOf[HTMLElement]
+        html.innerText = "Unoffical Foods"
+        html
+      })
+    )),
+    display = {(x: USDANodeNoId) => x.name}
+  )
+
+  private val _ingredients = Var[Seq[Ingredient]](_defaultRecipe.ingredients)
+  val ingredients = _ingredients.signal
+  private val _ingredientSearch = input(typ := "text",
+    cls := "form-control input-sm",
+    onMountCallback({(ctx) =>
+      val elm = ctx.thisNode
+      TypeaheadElement[FoodNode](
+        elm.ref.asInstanceOf[HTMLInputElement],
+        minLength = 3
+      )(Seq(internalFoodDS, usdaFoodDS))
+      elm.amend(
+        Typeahead.onSelected[FoodNode] --> { (e: CursorEvent[FoodNode]) =>
+          e.selectable.map(_.data).foreach({ node =>
+            // TODO: default unit
+            _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
+              _ingredients.update(_ :+ in)
+              elm.ref.value = ""
+              Future.successful(())
+            }
+          })
+        }
+      )
+    })
+  )
+
+  private def _editIngredient(ing: Ingredient)(
+    callback: (Ingredient => Future[_])
+  ) = {
+    val amount = Var[Float](ing.amount)
+    val unit = Var[MeasureUnit](Gram)
+
+    Overlay.confirmFuture(ing.food.map({ (food) =>
+      val amountIn = input(typ := "number",
+        cls := "form-control input-sm col-9",
+        minAttr := "0",
+        defaultValue := ing.amount.toString,
+        onInput.mapToValue.map(_.toFloat) --> amount
+      )
+      val unitIn = select(cls := "col-3 custom-select",
+        MeasureUnit.units.zipWithIndex.map({ case (unit, idx) =>
+          // TODO: default selected dynamic
+          option(value := idx.toString,
+            selected := unit.abr == Gram.abr,
+            unit.name
+          )
+        }),
+        onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> unit
+      )
+
+      div(cls := "row", amountIn, unitIn)
+    }),
+      amount.signal.recoverToTry.map(_.isSuccess) &&
+        unit.signal.recoverToTry.map(_.isSuccess)
+    ) { () =>
+      callback(ing.copy(
+        amount = amount.now(),
+        unit = unit.now()
+      ))
+    }
+  }
+
+  private def _presentFoodNode(idx: Int)(ingredientSig: Signal[Ingredient]) =
+    li(children <-- ingredientSig.flatMap { (ingredient: Ingredient) =>
+      Signal.fromFuture(ingredient.food).optionMap { (food) =>
+        Seq(
+          span(cls := "ui-icon ui-icon-pencil",
+            onClick --> { (e: Event) =>
+              _editIngredient(ingredient) { (in) =>
+                _ingredients.update(_.updated(idx, in))
+                Future.successful(())
+              }
+            }
+          ),
+          span(cls := "ui-icon ui-icon-close",
+            onClick --> { (e: Event) =>
+              _ingredients.update(_.filterNot(_ == ingredient))
+            }
+          ),
+          span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}")
+        )
+      } withDefault(Nil)
+    })
+
+  private val _ENTER_KEY_CODE = 13
+
+  private val _steps = Var[Seq[String]](_defaultRecipe.steps)
+  val steps = _steps.signal
+  private val _stepIn = input(idAttr := "step",
+    cls := "form-control",
+    onKeyPress --> { (e: KeyboardEvent) => if (e.keyCode == _ENTER_KEY_CODE) {
+      val elm = e.target.asInstanceOf[HTMLInputElement]
+      _steps.update(_ :+ elm.value)
+      elm.value = ""
+    } }
+  )
+
+  private def _removeFromList[T](s: Seq[T], idx: Int) =
+    s.take(idx) ++ s.drop(idx + 1)
+
+  private val _stepList = ol(
+    listStyleType := "none",
+    paddingLeft := "0",
+    children <-- _steps.signal.map(_.zipWithIndex.map((_presentStep _).tupled)),
+    onMountCallback({(ctx) =>
+      Sortable.create(ctx.thisNode.ref, SortableOptions.onUpdate({
+      (event: SortableEvent) => 
+        _steps.update({ steps =>
+          (event.oldIndex.toOption, event.newIndex.toOption) match {
+            case (Some(old), Some(ne)) if (old < ne) =>
+              moveBackwards(steps, old, ne)
+            case (Some(old), Some(ne)) =>
+              moveBackwards(
+                steps.reverse,
+                steps.size - old - 1,
+                steps.size - ne - 1
+              ).reverse
+            case (None, Some(ne)) =>
+              val (first, second) = steps.splitAt(ne)
+              (first :+ event.item.innerText) ++ second
+            case (Some(old), None) =>
+              _removeFromList(steps, old)
+          }
+        })
+        // If the added element is still in the parent, remove it since the
+        // Rx.update will have generated a new one.
+        Option(event.item.parentElement).map(_.removeChild(event.item))
+
+        def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
+          (steps.take(old) ++
+            steps.take(ne+1).drop(old + 1) :+
+            steps(old)) ++
+            steps.drop(ne+1)
+      }))
+    })
+  )
+
+  private def _presentStep(step: String, idx: Int) = li(
+    span(cls := "ui-icon ui-icon-close",
+      onClick --> { (e: Event) =>
+        _steps.update(_removeFromList(_, idx))
+      }
+    ),
+    step
+  )
+
+  private val _srvAmtXunit = massPerUnit.combineWithFn(_volumePerServ, /* _discreet */ Val(false))({
+    case (_, _, true)         => Success((1.0f, NUMBER))
+    case (Some(mass), _, _)   => Success((mass, MASS))
+    case (_, Some(volume), _) => Success((volume, VOLUME))
+    case (None, None, false)  => Failure(new IllegalStateException("None of mass, density, or count are enabled."))
+  }).throwFailure
+
+  val defaultUnitType = _srvAmtXunit.map(_._2)
+  val stdQtiesPServing = _srvAmtXunit.map((x) => x._1 / x._2.standardQuanity)
+  val stdQties = _servings.combineWithFn(stdQtiesPServing)(_ * _) 
+  val density = massPerUnit.combineWithFn(_volumePerServ)(_.zip(_).map {
+    case (m, v) => (m/v).toFloat
+  })
+
+  val newInstance: Signal[Try[RecipeNodeNoId]] =
+    name.combineWithFn(stdQties, stdQtiesPServing, defaultUnitType,
+      ingredients, steps, density, massPerUnit.signal) {
+      case (
+        name, stdQties, stdQtiesPServing, defaultUnitType, ingredients, steps,
+        density, massPerUnit
+      ) => Success(RecipeNodeNoId(
+        name, stdQties, stdQtiesPServing, defaultUnitType, ingredients, steps,
+        density, massPerUnit
+      ))
+    }
+
+  protected def renderEditPane(): Node =
+    form(cls := "form-group",
+      div(cls := "container",
+        div(cls := "row",
+          div(cls := "col-md-12",
+            label("Name: "),
+            _nameIn
+          )
+        ),
+        div(cls := "row",
+          div(cls := "col-md-2 form-check",
+          /*
+            div(cls := "form-check",
+              _discreetIn,
+              label(cls := "form-check-label", 
+                forId := "discreet",
+                "Descrete Servings"
+              )
+            )*/
+          ),
+          div(cls := "col-md-3 input-group",
+            _servingsIn,
+            div(cls := "input-group-append",
+              label(cls := "input-group-text", "Servings")
+            )
+          ),
+          div(cls := "col-md-3 input-group",
+            _massPerUnitIn,
+            div(cls := "input-group-append",
+              label(cls := "input-group-text", "g/Serving")
+            )
+          ),
+          div(cls := "col-md-4 input-group",
+            _volumePerServIn,
+            _volumeUnitIn,
+            div(cls := "input-group-append",
+              label(cls := "input-group-text", "/Serving"),
+            ),
+          )
+        ),
+        div(cls := "row",
+          div(cls := "col-md-6",
+            h2("Ingredients"),
+            _ingredientSearch,
+            ul(
+              listStyleType := "none",
+              paddingLeft := "0",
+              children <-- _ingredients.signal.splitByIndex {
+                case (idx, _, ingredientStream) =>
+                  _presentFoodNode(idx)(ingredientStream)
+              }
+            )
+          ),
+          div(cls := "col-md-6",
+            h2("Steps"),
+            _stepIn,
+            _stepList
+          )
+        )
+      )
+    )
+}

+ 2 - 2
webClient/src/main/scala/com/weEat/modules/Navbar.scala

@@ -6,7 +6,7 @@ import com.weEat.view.View
 
 case class Navbar() extends Module {
   val render = ul(cls := "navbar-nav mr-auto mt-2 mt-lg-0",
-    children <-- View.authedIndex.split(_.tag) {
+    children <-- View.authedIndex.map(_.filter(_.navName.isDefined)).split(_.tag) {
       case (tag, _, viewStream) => li(
         cls <-- View.router.currentPageSignal.map(_.t)
           .valueIs(tag)
@@ -19,7 +19,7 @@ case class Navbar() extends Module {
               View.router.pushState(viewStream.observe.now().defaultPage)
             }
           }),
-          child.text <-- viewStream.map(_.navName)
+          child.text <-- viewStream.map(_.navName.get)
         )
       )
     },

+ 3 - 3
webClient/src/main/scala/com/weEat/modules/TypeaheadRx.scala

@@ -5,10 +5,10 @@ import com.weEat.modules._
 import com.tflucke.typeahead._
 import scala.concurrent.ExecutionContext
 
-case class TypeaheadRx[T](val datasets: Dataset[T]*)
+case class Typeahead[T](val datasets: Dataset[T]*)
 (implicit val ec: ExecutionContext) extends Module {
 
-  import TypeaheadRx._
+  import Typeahead._
 
   private val _query = Var("")
   private val _result = Var[Option[T]](None)
@@ -35,7 +35,7 @@ case class TypeaheadRx[T](val datasets: Dataset[T]*)
   val value = _result.signal
 }
 
-object TypeaheadRx {
+object Typeahead {
   def onSelected[T] = new EventProp[CursorEvent[T]]("typeahead:selected")
   val onQueryChanged = new EventProp[QueryEvent]("typeahead:queryChanged")
 }

+ 1 - 0
webClient/src/main/scala/com/weEat/modules/USDAEditor.scala

@@ -12,6 +12,7 @@ import gov.usda.nal.fdc.models.{FullFoodItem,FoodPortion}
 import com.weEat.models.Nutrient
 import com.weEat.shared.models.{Count,MeasureUnit,USDANode,ParsedMeasure}
 
+// TODO: Use the default conversion from FoodNode to replace the default logic here.
 case class USDAEditor(
   usda: USDANode,
   defaultOverNone: Boolean

+ 24 - 5
webClient/src/main/scala/com/weEat/views/FoodSearch.scala

@@ -8,11 +8,12 @@ import play.api.libs.json.{JsValue,Json}
 import com.weEat.modules._
 import com.weEat.controllers.FoodController
 import com.weEat.shared.models._
+import com.weEat.OAuthSignal
 
 object FoodSearch extends View[Option[String]] {
   implicit val ctx = com.weEat.shared.ctx
 
-  val navName = "Food Search"
+  val navName = Some("Food Search")
   val tag = "foodsearch"
 
   private val SEARCH_PAGE_SIZE = 40.asInstanceOf[Short]
@@ -53,11 +54,29 @@ object FoodSearch extends View[Option[String]] {
           View.router.pushState(ViewPage(q = str))
       },
       PaginatedTable[FoodNodeId](Seq(
-        ("", 1, { (x) => button(cls := "btn btn-light",
-          "View"
-        )}),
+        ("", 2, { (x) => div(
+          button(cls := "btn btn-light",
+            onClick --> { (_) =>
+              View.router.pushState(RecipeView.ViewPage(x._id))
+            },
+            "View"
+          ),
+          children <-- Signal.fromFuture(x.user).map({ (userOpt) =>
+            userOpt.map { (user) =>
+              OAuthSignal.username.valueIs(Some(user.email))
+            }
+          }).shiftOption.optionMap({
+            case true => button(cls := "btn btn-light",
+              onClick --> { (_) =>
+                View.router.pushState(RecipeEdit.ViewPage(Some(x._id)))
+              },
+              "Edit"
+            )
+            case false => span()
+          }).map(_.toSeq)
+        ) }),
         ("Name", 3, { (x) => span(x.name)}),
-        ("Type", 4, { (x) => span(x.nodeType.toString)}),
+        ("Type", 3, { (x) => span(x.nodeType.toString)}),
         ("User", 2, { (x) => span(futureChild <-- x.user.map { _.email })}),
         ("Source", 2, { (x) => span()})
       ),

+ 56 - 340
webClient/src/main/scala/com/weEat/views/RecipeEdit.scala

@@ -6,18 +6,12 @@ import com.raquo.airstream.ownership.ManualOwner
 import com.raquo.waypoint._
 
 import play.api.libs.json.{JsValue,Json}
-import com.weEat.controllers.{FoodController,USDAController}
 import com.weEat.modules._
-import com.weEat.shared.models.UnitType._
 import com.weEat.shared.models._
-import org.scalajs.dom.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
-import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
-import com.tflucke.typeahead._
-import com.tflucke.typeahead.Dataset.Templates
-import org.scalajs.dom.document
-import gov.usda.nal.fdc.models.DataType._
-import scala.concurrent.Future
-import scala.util.{Success,Failure}
+import com.weEat.models.RecipeVar
+import org.scalajs.dom.Event
+import com.weEat.controllers.FoodController
+import scala.util.{Failure,Success}
 
 // TODO: prevent user from not having any of discreet/mass/volume input 
 // TODO: Save recipe node in cookie until ready to use
@@ -26,7 +20,7 @@ object RecipeEdit extends View[Option[String]] {
 
   implicit val ec = com.weEat.shared.ctx
 
-  val navName = "New Recipe"
+  val navName = Some("New Recipe")
   val tag = "editRecipe"
 
   case class ViewPage(val id: Option[String] = None) extends P {
@@ -41,343 +35,65 @@ object RecipeEdit extends View[Option[String]] {
   )
   def defaultPage = ViewPage()
 
-  val ENTER_KEY_CODE = 13
-
   override def permissions = Set("user")
 
-  private val _nameIn = input(idAttr := "name",
-    cls := "form-control",
-    required := true
-  )
-  private val _name: Signal[String] = _nameIn.value
-
-  private val internalFoodDS = Dataset(
-    { _ => Nil},
-    Some({ (str: String) => FoodController.query(str.toLowerCase)() }),
-    display = { (food: FoodNodeId) => food.name }
-  )
-
-  private val usdaFoodDS = Dataset(
-    { _ => Nil},
-    Some({ str: String =>
-      USDAController.getFoodsSearch(str, Seq(
-        Foundation, Survey, SRLegacy
-      ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
-    }),
-    templates = Some(Templates({(x: USDANodeNoId) => x.name}).copy(
-      header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
-        val html = document.createElement("div").asInstanceOf[HTMLElement]
-        html.innerText = "Unoffical Foods"
-        html
-      })
-    )),
-    display = {(x: USDANodeNoId) => x.name}
-  )
-
-  private val _ingredientSearch = input(typ := "text",
-    cls := "form-control input-sm",
-    onMountCallback({(ctx) =>
-      val elm = ctx.thisNode
-      TypeaheadElement[FoodNode](
-        elm.ref.asInstanceOf[HTMLInputElement],
-        minLength = 3
-      )(Seq(internalFoodDS, usdaFoodDS))
-      elm.amend(
-        TypeaheadRx.onSelected[FoodNode] --> { (e: CursorEvent[FoodNode]) =>
-          e.selectable.map(_.data).foreach({ node =>
-            // TODO: default unit
-            _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
-              _ingredients.update(_ :+ in)
-              elm.ref.value = ""
-              Future.successful(())
-            }
-          })
-        }
-      )
-    })
-  )
-
-  private val _stepIn = input(idAttr := "step",
-    cls := "form-control",
-    onKeyPress --> { (e: KeyboardEvent) => if (e.keyCode == ENTER_KEY_CODE) {
-      val elm = e.target.asInstanceOf[HTMLInputElement]
-      _steps.update(_ :+ elm.value)
-      elm.value = ""
-    } }
-  )
-
-  private val _ingredients = Var[Seq[Ingredient]](Nil)
-  private val _steps = Var[Seq[String]](Nil)
-
-  private val _servingsIn = input(idAttr := "serv",
-    typ := "number",
-    cls := "form-control",
-    defaultValue := "4",
-    stepAttr := "0.1",
-    required := true
-  )
-  private val _servings = _servingsIn.value
-
-  private val _discreetIn = input(idAttr := "discreet",
-    typ := "checkbox",
-    cls := "form-check-input",
-    defaultChecked := true
-  )
-  private val _discreet = _discreetIn.checked
-
-  // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
-  private val _massPIn = input(idAttr := "massP",
-    cls := "form-control",
-    typ := "number",
-    minAttr := "1",
-    stepAttr := "0.1",
-    value := "100"
-  )
-  private val _massP = _massPIn.value
-
-  // TODO: Try autopopulate ingredients.averge(_.density)
-  private val _volPIn = input(idAttr := "volP",
-    cls := "form-control",
-    typ := "number",
-    minAttr := "0",
-    stepAttr := "0.1" 
-  )
-  private val _volP = _volPIn.value
-
-  private val _volPUnit = Var[MeasureUnit](Milliliter)
-  private val _volPUnitIn = select(cls := "custom-select",
-    MeasureUnit.units.zipWithIndex
-      .filter({ case (u, _) => u.typ == VOLUME })
-      .map({ case (unit, idx) =>
-        // TODO: default selected dynamic
-        option(
-          value := idx.toString,
-          selected := unit.abr == Milliliter.abr,
-          unit.abr
-        )
-      }),
-    onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> _volPUnit
-  )
-
-  val recipieNode = _ingredients.signal.combineWithFn(
-    _steps,
-    _name,
-    _discreet,
-    _servings,
-    _volP,
-    _volPUnit,
-    _massP
-  ) { case (ingredients, steps, name, discreet, servings, volP, volPUnit,
-    massP) =>
-      val gPServ = massP.toFloatOption
-      val mLPServ = volP.toFloatOption
-        .map( _ * volPUnit.conversionRatio )
-      val (servFactor, servUnit) = gPServ.zip(Some(Gram))
-        .getOrElse(
-          if (discreet) (1.0f, Count)
-          else mLPServ.zip(Some(Milliliter)).getOrElse(???)
-        ).asInstanceOf[(Float, MeasureUnit)]
-      val numServings = servings.toFloatOption.getOrElse(1.0f)
-      val stdQtiesPServing = servFactor / servUnit.typ.standardQuanity
-      val stdQties = numServings * stdQtiesPServing
-
-      RecipeNodeNoId(
-        name,
-        stdQties,
-        stdQtiesPServing,
-        servUnit.typ,
-        ingredients,
-        steps,
-        gPServ.zip(mLPServ).map({ case (m, v) => (m/v).toFloat }),
-        gPServ
-      )
-    }
-
-  private def _editIngredient(ing: Ingredient)(callback: (Ingredient => Future[_])) = {
-    val amount = Var[Float](ing.amount)
-    val unit = Var[MeasureUnit](Gram)
-
-    Overlay.confirmFuture(ing.food.map({ (food) =>
-      val amountIn = input(typ := "number",
-        cls := "form-control input-sm col-9",
-        minAttr := "0",
-        defaultValue := ing.amount.toString,
-        onInput.mapToValue.map(_.toFloat) --> amount
-      )
-      val unitIn = select(cls := "col-3 custom-select",
-        MeasureUnit.units.zipWithIndex.map({ case (unit, idx) =>
-          // TODO: default selected dynamic
-          option(value := idx.toString,
-            selected := unit.abr == Gram.abr,
-            unit.name
-          )
-        }),
-        onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> unit
-      )
-
-      div(cls := "row", amountIn, unitIn)
-    }),
-      amount.signal.recoverToTry.map(_.isSuccess) &&
-        unit.signal.recoverToTry.map(_.isSuccess)
-    ) { () =>
-      callback(ing.copy(
-        amount = amount.now(),
-        unit = unit.now()
+  def content(page: Signal[ViewPage]) = {
+    // tflucke@[2023-10-09] TODO: Make sure we *only* get recipe nodes back from
+    // this API
+    val refRecipe: Signal[Option[RecipeNode]] = page.map(
+      _.id.map((id) => Signal.fromFuture(
+        FoodController.get(id)().map(_.asInstanceOf[RecipeNode])
       ))
-    }
-  }
-
-  private def _removeFromList[T](s: Seq[T], idx: Int) =
-    s.take(idx) ++ s.drop(idx + 1)
-
-  private val _stepList = ol(
-    listStyleType := "none",
-    paddingLeft := "0",
-    children <-- _steps.signal.map(_.zipWithIndex.map((presentStep _).tupled)),
-    onMountCallback({(ctx) =>
-      Sortable.create(ctx.thisNode.ref, SortableOptions.onUpdate({
-      (event: SortableEvent) => 
-        _steps.update({ steps =>
-          (event.oldIndex.toOption, event.newIndex.toOption) match {
-            case (Some(old), Some(ne)) if (old < ne) =>
-              moveBackwards(steps, old, ne)
-            case (Some(old), Some(ne)) =>
-              moveBackwards(
-                steps.reverse,
-                steps.size - old - 1,
-                steps.size - ne - 1
-              ).reverse
-            case (None, Some(ne)) =>
-              val (first, second) = steps.splitAt(ne)
-              (first :+ event.item.innerText) ++ second
-            case (Some(old), None) =>
-              _removeFromList(steps, old)
-          }
-        })
-        // If the added element is still in the parent, remove it since the
-        // Rx.update will have generated a new one.
-        Option(event.item.parentElement).map(_.removeChild(event.item))
-
-        def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
-          (steps.take(old) ++
-            steps.take(ne+1).drop(old + 1) :+
-            steps(old)) ++
-            steps.drop(ne+1)
-      }))
-    })
-  )
-
-  def presentFoodNode(idx: Int)(ingredientSig: Signal[Ingredient]) =
-    li(children <-- ingredientSig.flatMap { (ingredient: Ingredient) =>
-      Signal.fromFuture(ingredient.food).optionMap { (food) =>
-        Seq(
-          span(cls := "ui-icon ui-icon-pencil",
-            onClick --> { (e: Event) =>
-              _editIngredient(ingredient) { (in) =>
-                _ingredients.update(_.updated(idx, in))
-                Future.successful(())
-              }
-            }
-          ),
-          span(cls := "ui-icon ui-icon-close",
-            onClick --> { (e: Event) =>
-              _ingredients.update(_.filterNot(_ == ingredient))
-            }
-          ),
-          span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}")
-        )
-      } withDefault(Nil)
-    })
-
-  def presentStep(step: String, idx: Int) = li(
-    span(cls := "ui-icon ui-icon-close",
-      onClick --> { (e: Event) =>
-        _steps.update(_removeFromList(_, idx))
-      }
-    ),
-    step
-  )
-
-  def content(page: Signal[ViewPage]) = div(
-    h2("Recipe Editor"),
-    div(cls := "form-group",
-      div(cls := "container",
-        div(cls := "row",
-          div(cls := "col-md-12",
-            label("Name: "),
-            _nameIn
-          )
-        ),
-        div(cls := "row",
-          div(cls := "col-md-3 form-check",
-            div(cls := "form-check",
-              _discreetIn,
-              label(cls := "form-check-label", 
-                forId := "discreet",
-                "Descrete Servings"
-              )
-            )
-          ),
-          div(cls := "col-md-3 input-group",
-            _servingsIn,
-            div(cls := "input-group-append",
-              label(cls := "input-group-text", "Servings")
-            )
-          ),
-          div(cls := "col-md-3 input-group",
-            _massPIn,
-            div(cls := "input-group-append",
-              label(cls := "input-group-text", "g/Serving")
-            )
-          ),
-          div(cls := "col-md-3 input-group",
-            _volPIn,
-            _volPUnitIn,
-            div(cls := "input-group-append",
-              label(cls := "input-group-text", "/Serving"),
+    ).shiftOption.map(_.flatten)
+    val recipeVar = refRecipe.map(RecipeVar(_))
+    val recipe = recipeVar.flatMap(_.newInstance)
+
+    div(
+      h2("Recipe Editor"),
+      div(cls := "form-group",
+        div(cls := "container",
+          div(cls := "row",
+            div(cls := "col-md-10",
+              child <-- recipeVar.map(_.render())
             ),
+            div(cls := "col-md-2",
+              NutritionPane(recipe.throwFailure.recoverIgnoreErrors).render
+            )
           )
         ),
-        div(cls := "row",
-          div(cls := "col-md-5",
-            h2("Ingredients"),
-            _ingredientSearch,
-            ul(
-              listStyleType := "none",
-              paddingLeft := "0",
-              children <-- _ingredients.signal.splitByIndex {
-                case (idx, _, ingredientStream) =>
-                  presentFoodNode(idx)(ingredientStream)
-              }
+        child <-- refRecipe.map({
+          case Some(RecipeNodeId(id, uid, _, _, _, _, _, _, _, _)) =>
+            button(cls := "btn",
+              onClick --> { (e: Event) =>
+                implicit val owner = new ManualOwner()
+
+                FoodController.update(id, Some(uid))(recipe.observe.now().get)
+                  .onComplete {
+                    case Success(recipe) =>
+                      View.router.pushState(RecipeView.ViewPage(recipe._id))
+                    case Failure(ex) =>
+                      println("Could not update recipe")
+                      throw ex
+                  }
+              },
+              "Update"
             )
-          ),
-          div(cls := "col-md-5",
-            h2("Steps"),
-            _stepIn,
-            _stepList
-          ),
-          div(cls := "col-md-2",
-            NutritionPane(recipieNode).render
+          case _ => button(cls := "btn",
+            onClick --> { (e: Event) =>
+              implicit val owner = new ManualOwner()
+
+              FoodController.add()(recipe.observe.now().get).onComplete {
+                case Success(recipe) =>
+                  View.router.pushState(RecipeView.ViewPage(recipe._id))
+                case Failure(ex) =>
+                  println("Could not add recipe")
+                  throw ex
+              }
+            },
+            "Add"
           )
-        )
-      ),
-      button(cls := "btn",
-        onClick --> { (e: Event) =>
-          implicit val owner = new ManualOwner()
-
-          // import play.api.libs.json.Json
-          // println(recipieNode.value)
-          // println(Json.toJson(recipieNode.value))
-          // println(Json.stringify(Json.toJson(recipieNode.value)))
-          FoodController.add()(recipieNode.observe.now()).onComplete {
-            case Success(_) => println("Success!")
-            case Failure(ex) =>
-              println("Could not add recipe")
-              throw ex
-          }
-        },
-        "Add"
+        })
       )
     )
-  )
+  }
 }

+ 109 - 370
webClient/src/main/scala/com/weEat/views/RecipeView.scala

@@ -1,370 +1,109 @@
-// package com.weEat.view
-
-// import com.raquo.laminar.api.L._
-// import io.laminext.syntax.core._
-// import com.raquo.airstream.ownership.ManualOwner
-
-// import com.weEat.controllers.{FoodController,USDAController}
-// import com.weEat.modules._
-// import com.weEat.shared.models.UnitType._
-// import com.weEat.shared.models._
-// import org.scalajs.dom.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
-// import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
-// import com.tflucke.typeahead._
-// import com.tflucke.typeahead.Dataset.Templates
-// import org.scalajs.dom.document
-// import gov.usda.nal.fdc.models.DataType._
-// import scala.concurrent.Future
-// import scala.util.{Success,Failure}
-
-// // TODO: prevent user from not having any of discreet/mass/volume input 
-// // TODO: Save recipe node in cookie until ready to use
-// object RecipeEdit extends View {
-//   import com.weEat.Main.headers
-
-//   implicit val ec = com.weEat.shared.ctx
-
-//   val tag = "viewRecipe"
-
-//   val title = "Edit Recipe"
-
-//   val ENTER_KEY_CODE = 13
-
-//   override def permissions = Set("user")
-
-//   private val _nameIn = input(idAttr := "name",
-//     cls := "form-control",
-//     required := true
-//   )
-//   private val _name: Signal[String] = _nameIn.value
-
-//   private val internalFoodDS = Dataset(
-//     { _ => Nil},
-//     Some({ (str: String) => FoodController.query(str.toLowerCase)() }),
-//     display = { food: FoodNodeId => food.name }
-//   )
-
-//   private val usdaFoodDS = Dataset(
-//     { _ => Nil},
-//     Some({ str: String =>
-//       USDAController.getFoodsSearch(str, Seq(
-//         Foundation, Survey, SRLegacy
-//       ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
-//     }),
-//     templates = Some(Templates({x: USDANodeNoId => x.name}).copy(
-//       header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
-//         val html = document.createElement("div").asInstanceOf[HTMLElement]
-//         html.innerText = "Unoffical Foods"
-//         html
-//       })
-//     )),
-//     display = {x: USDANodeNoId => x.name}
-//   )
-
-//   private val _ingredientSearch = input(typ := "text",
-//     cls := "form-control input-sm",
-//     onMountCallback({(ctx) =>
-//       val elm = ctx.thisNode
-//       TypeaheadElement[FoodNode](
-//         elm.ref.asInstanceOf[HTMLInputElement],
-//         minLength = 3
-//       )(Seq(internalFoodDS, usdaFoodDS))
-//       elm.amend(
-//         TypeaheadRx.onSelected[FoodNode] --> { (e: CursorEvent[FoodNode]) =>
-//           e.selectable.map(_.data).foreach({ node =>
-//             // TODO: default unit
-//             _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
-//               _ingredients.update(_ :+ in)
-//               elm.ref.value = ""
-//               Future.successful(())
-//             }
-//           })
-//         }
-//       )
-//     })
-//   )
-
-//   private val _stepIn = input(idAttr := "step",
-//     cls := "form-control",
-//     onKeyPress --> { (e: KeyboardEvent) => if (e.keyCode == ENTER_KEY_CODE) {
-//       val elm = e.target.asInstanceOf[HTMLInputElement]
-//       _steps.update(_ :+ elm.value)
-//       elm.value = ""
-//     } }
-//   )
-
-//   private val _ingredients = Var[Seq[Ingredient]](Nil)
-//   private val _steps = Var[Seq[String]](Nil)
-
-//   private val _servingsIn = input(idAttr := "serv",
-//     typ := "number",
-//     cls := "form-control",
-//     defaultValue := "4",
-//     stepAttr := "0.1",
-//     required := true
-//   )
-//   private val _servings = _servingsIn.value
-
-//   private val _discreetIn = input(idAttr := "discreet",
-//     typ := "checkbox",
-//     cls := "form-check-input",
-//     defaultChecked := true
-//   )
-//   private val _discreet = _discreetIn.checked
-
-//   // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
-//   private val _massPIn = input(idAttr := "massP",
-//     cls := "form-control",
-//     typ := "number",
-//     minAttr := "1",
-//     stepAttr := "0.1",
-//     value := "100"
-//   )
-//   private val _massP = _massPIn.value
-
-//   // TODO: Try autopopulate ingredients.averge(_.density)
-//   private val _volPIn = input(idAttr := "volP",
-//     cls := "form-control",
-//     typ := "number",
-//     minAttr := "0",
-//     stepAttr := "0.1" 
-//   )
-//   private val _volP = _volPIn.value
-
-//   private val _volPUnit = Var[MeasureUnit](Milliliter)
-//   private val _volPUnitIn = select(cls := "custom-select",
-//     MeasureUnit.units.zipWithIndex
-//       .filter({ case (u, _) => u.typ == VOLUME })
-//       .map({ case (unit, idx) =>
-//         // TODO: default selected dynamic
-//         option(
-//           value := idx.toString,
-//           selected := unit.abr == Milliliter.abr,
-//           unit.abr
-//         )
-//       }),
-//     onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> _volPUnit
-//   )
-
-//   val recipieNode = _ingredients.signal.combineWithFn(
-//     _steps,
-//     _name,
-//     _discreet,
-//     _servings,
-//     _volP,
-//     _volPUnit,
-//     _massP
-//   ) { case (ingredients, steps, name, discreet, servings, volP, volPUnit,
-//     massP) =>
-//       val gPServ = massP.toFloatOption
-//       val mLPServ = volP.toFloatOption
-//         .map( _ * volPUnit.conversionRatio )
-//       val (servFactor, servUnit) = gPServ.zip(Some(Gram))
-//         .getOrElse(
-//           if (discreet) (1.0f, Count)
-//           else mLPServ.zip(Some(Milliliter)).getOrElse(???)
-//         ).asInstanceOf[(Float, MeasureUnit)]
-//       val numServings = servings.toFloatOption.getOrElse(1.0f)
-//       val stdQtiesPServing = servFactor / servUnit.typ.standardQuanity
-//       val stdQties = numServings * stdQtiesPServing
-
-//       RecipeNodeNoId(
-//         name,
-//         stdQties,
-//         stdQtiesPServing,
-//         servUnit.typ,
-//         ingredients,
-//         steps,
-//         gPServ.zip(mLPServ).map({ case (m, v) => (m/v).toFloat }),
-//         gPServ
-//       )
-//     }
-
-//   private def _editIngredient(ing: Ingredient)(callback: (Ingredient => Future[_])) = {
-//     val amount = Var[Float](ing.amount)
-//     val unit = Var[MeasureUnit](Gram)
-
-//     Overlay.confirmFuture(ing.food.map({ (food) =>
-//       val amountIn = input(typ := "number",
-//         cls := "form-control input-sm col-9",
-//         minAttr := "0",
-//         defaultValue := ing.amount.toString,
-//         onInput.mapToValue.map(_.toFloat) --> amount
-//       )
-//       val unitIn = select(cls := "col-3 custom-select",
-//         MeasureUnit.units.zipWithIndex.map({ case (unit, idx) =>
-//           // TODO: default selected dynamic
-//           option(value := idx.toString,
-//             selected := unit.abr == Gram.abr,
-//             unit.name
-//           )
-//         }),
-//         onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> unit
-//       )
-
-//       div(cls := "row", amountIn, unitIn)
-//     }),
-//       amount.signal.recoverToTry.map(_.isSuccess) &&
-//         unit.signal.recoverToTry.map(_.isSuccess)
-//     ) { () =>
-//       callback(ing.copy(
-//         amount = amount.now(),
-//         unit = unit.now()
-//       ))
-//     }
-//   }
-
-//   private def _removeFromList[T](s: Seq[T], idx: Int) =
-//     s.take(idx) ++ s.drop(idx + 1)
-
-//   private val _stepList = ol(
-//     listStyleType := "none",
-//     paddingLeft := "0",
-//     children <-- _steps.signal.map(_.zipWithIndex.map((presentStep _).tupled)),
-//     onMountCallback({(ctx) =>
-//       Sortable.create(ctx.thisNode.ref, SortableOptions.onUpdate({
-//       event: SortableEvent => 
-//         _steps.update({ steps =>
-//           (event.oldIndex.toOption, event.newIndex.toOption) match {
-//             case (Some(old), Some(ne)) if (old < ne) =>
-//               moveBackwards(steps, old, ne)
-//             case (Some(old), Some(ne)) =>
-//               moveBackwards(
-//                 steps.reverse,
-//                 steps.size - old - 1,
-//                 steps.size - ne - 1
-//               ).reverse
-//             case (None, Some(ne)) =>
-//               val (first, second) = steps.splitAt(ne)
-//               (first :+ event.item.innerText) ++ second
-//             case (Some(old), None) =>
-//               _removeFromList(steps, old)
-//           }
-//         })
-//         // If the added element is still in the parent, remove it since the
-//         // Rx.update will have generated a new one.
-//         Option(event.item.parentElement).map(_.removeChild(event.item))
-
-//         def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
-//           (steps.take(old) ++
-//             steps.take(ne+1).drop(old + 1) :+
-//             steps(old)) ++
-//             steps.drop(ne+1)
-//       }))
-//     })
-//   )
-
-//   def presentFoodNode(idx: Int)(ingredientSig: Signal[Ingredient]) =
-//     li(children <-- ingredientSig.flatMap { (ingredient: Ingredient) =>
-//       Signal.fromFuture(ingredient.food).optionMap { (food) =>
-//         Seq(
-//           span(cls := "ui-icon ui-icon-pencil",
-//             onClick --> { (e: Event) =>
-//               _editIngredient(ingredient) { (in) =>
-//                 _ingredients.update(_.updated(idx, in))
-//                 Future.successful(())
-//               }
-//             }
-//           ),
-//           span(cls := "ui-icon ui-icon-close",
-//             onClick --> { (e: Event) =>
-//               _ingredients.update(_.filterNot(_ == ingredient))
-//             }
-//           ),
-//           span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}")
-//         )
-//       } withDefault(Nil)
-//     })
-
-//   def presentStep(step: String, idx: Int) = li(
-//     span(cls := "ui-icon ui-icon-close",
-//       onClick --> { (e: Event) =>
-//         _steps.update(_removeFromList(_, idx))
-//       }
-//     ),
-//     step
-//   )
-
-//   def content = div(
-//     h2("Recipe Editor"),
-//     div(cls := "form-group",
-//       div(cls := "container",
-//         div(cls := "row",
-//           div(cls := "col-md-12",
-//             label("Name: "),
-//             _nameIn
-//           )
-//         ),
-//         div(cls := "row",
-//           div(cls := "col-md-3 form-check",
-//             div(cls := "form-check",
-//               _discreetIn,
-//               label(cls := "form-check-label", 
-//                 forId := "discreet",
-//                 "Descrete Servings"
-//               )
-//             )
-//           ),
-//           div(cls := "col-md-3 input-group",
-//             _servingsIn,
-//             div(cls := "input-group-append",
-//               label(cls := "input-group-text", "Servings")
-//             )
-//           ),
-//           div(cls := "col-md-3 input-group",
-//             _massPIn,
-//             div(cls := "input-group-append",
-//               label(cls := "input-group-text", "g/Serving")
-//             )
-//           ),
-//           div(cls := "col-md-3 input-group",
-//             _volPIn,
-//             _volPUnitIn,
-//             div(cls := "input-group-append",
-//               label(cls := "input-group-text", "/Serving"),
-//             ),
-//           )
-//         ),
-//         div(cls := "row",
-//           div(cls := "col-md-5",
-//             h2("Ingredients"),
-//             _ingredientSearch,
-//             ul(
-//               listStyleType := "none",
-//               paddingLeft := "0",
-//               children <-- _ingredients.signal.splitByIndex {
-//                 case (idx, _, ingredientStream) =>
-//                   presentFoodNode(idx)(ingredientStream)
-//               }
-//             )
-//           ),
-//           div(cls := "col-md-5",
-//             h2("Steps"),
-//             _stepIn,
-//             _stepList
-//           ),
-//           div(cls := "col-md-2",
-//             NutritionPane(recipieNode).render
-//           )
-//         )
-//       ),
-//       button(cls := "btn",
-//         onClick --> { (e: Event) =>
-//           implicit val owner = new ManualOwner()
-
-//           // import play.api.libs.json.Json
-//           // println(recipieNode.value)
-//           // println(Json.toJson(recipieNode.value))
-//           // println(Json.stringify(Json.toJson(recipieNode.value)))
-//           FoodController.add()(recipieNode.observe.now()).onComplete {
-//             case Success(_) => println("Success!")
-//             case Failure(ex) =>
-//               println("Could not add recipe")
-//               throw ex
-//           }
-//         },
-//         "Add"
-//       )
-//     )
-//   )
-// }
+package com.weEat.view
+
+import com.raquo.laminar.api.L._
+import io.laminext.syntax.core._
+import com.raquo.waypoint._
+
+import play.api.libs.json.{JsValue,Json}
+import com.weEat.controllers.FoodController
+import com.weEat.modules._
+import com.weEat.shared.models._
+import com.weEat.shared.models.UnitType._
+
+// TODO: prevent user from not having any of discreet/mass/volume input 
+// TODO: Save recipe node in cookie until ready to use
+object RecipeView extends View[String] {
+  import com.weEat.Main.headers
+
+  implicit val ec = com.weEat.shared.ctx
+
+  val navName = None
+  val tag = "viewRecipe"
+
+  case class ViewPage(val id: String) extends P {
+    // TODO: Add recipe name to title
+    val title = "Recipe"
+    def jsonValue = Json.toJson(id)
+  }
+  def parseJson(jsVal: JsValue) = ViewPage(jsVal.as[String])
+  def route = Route.onlyQuery(
+    encode = (page: ViewPage) => page.id,
+    decode = (id: String) => ViewPage(id = id),
+    pattern = (root / tag / endOfSegments) ? param[String]("id")
+  )
+  def defaultPage = ???
+
+  val ENTER_KEY_CODE = 13
+
+  def presentFoodNode(ingredient: Ingredient): Node =
+    li(child <-- Signal.fromFuture(ingredient.food).optionMap({ (food) =>
+        span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}")
+      }).withDefault(span())
+    )
+
+  def content(page: Signal[ViewPage]) = {
+    val food: Signal[Option[RecipeNodeId]] = page.flatMap((s) => Signal.fromFuture(
+      FoodController.get(s.id)().map(_.asInstanceOf[RecipeNodeId])
+    ))
+
+    div(
+      h2("Recipe"),
+      div(cls := "form-group",
+        div(cls := "container",
+          div(cls := "row",
+            div(cls := "col-md-12",
+              child.text <-- food.optionMap(_.name).withDefault("loading...")
+            )
+          ),
+          div(cls := "row",
+            div(cls := "col-md-3",
+              "Servings: ",
+              child.text <-- food.optionMap(_.numServings.toString).withDefault("loading...")
+            ),
+            child <-- food.optionFlatMap((f) =>
+              f.massPerUnit
+                .map(div(cls := "col-md-3",
+                  "Serving Weight: ",  _, UnitType.defaultUnit(MASS).abr
+                ))
+            ).withDefault(div(cls := "col-md-3")),
+            child <-- food.optionFlatMap((f) =>
+              f.massPerUnit
+                .flatMap((mpu) => f.density.map(mpu / _))
+                .map((v) => div(cls := "col-md-3",
+                  "Servings Volume: ",
+                  f"$v%.1f",
+                  UnitType.defaultUnit(VOLUME).abr
+                ))
+            ).withDefault(div(cls := "col-md-3"))
+          ),
+          div(cls := "row",
+            div(cls := "col-md-5",
+              h2("Ingredients"),
+              ul(
+                listStyleType := "none",
+                paddingLeft := "0",
+                children <-- food
+                  .optionMap(_.ingredients.map(presentFoodNode(_)))
+                  .withDefault(Seq(span("loading...")))
+              )
+            ),
+            div(cls := "col-md-5",
+              h2("Steps"),
+              ol(
+                listStyleType := "none",
+                paddingLeft := "0",
+                children <-- food
+                  .optionMap(_.steps.map(li(_)))
+                  .withDefault(Seq(li("loading...")))
+              )
+            ),
+            div(cls := "col-md-2",
+              child <-- food.optionMap((r) => NutritionPane(Val(r)).render)
+                .withDefault(div())
+            )
+          )
+        )
+      )
+    )
+  }
+}

+ 1 - 1
webClient/src/main/scala/com/weEat/views/UsdaImporter.scala

@@ -19,7 +19,7 @@ object UsdaImporter extends View[Option[String]] {
 
   implicit val ctx = com.weEat.shared.ctx
 
-  val navName = "Usda Import"
+  val navName = Some("Usda Import")
   val tag = "importer"
 
   override val permissions = Set("admin")

+ 11 - 13
webClient/src/main/scala/com/weEat/views/View.scala

@@ -4,11 +4,12 @@ import com.raquo.laminar.api.L._
 import com.raquo.waypoint._
 import play.api.libs.json._
 import com.weEat.shared.OAuthManager
+import com.weEat.OAuthSignal
 
 trait View[Args] {
   type ViewPage <: P
 
-  val navName: String
+  val navName: Option[String]
   def tag: String
   def content(s: Signal[ViewPage]): com.raquo.laminar.nodes.ReactiveElement.Base
   def permissions: Set[String] = Set.empty
@@ -39,20 +40,16 @@ object View {
     FoodSearch,
     UsdaImporter,
     RecipeEdit,
-    //RecipeView,
+    RecipeView,
     // UserManage,
     //ProfileView,
     //ProfileEdit
   )
 
-  private def _currentFilteredIndex = {
-    val curScope = OAuthManager.currentScope
-    index.filter(_.permissions.forall(curScope.contains))
-  }
-  private val _filteredIndex = Var(_currentFilteredIndex)
-  OAuthManager.addObserver({() => _filteredIndex.set(_currentFilteredIndex)})
-  
-  def authedIndex = _filteredIndex.signal
+  private def _authedIndex(perms: Set[String]) =
+    index.filter(_.permissions.forall(perms.contains))
+
+  def authedIndex = OAuthSignal.permissions.map(_authedIndex)
 
   val router = new Router[Page](
     routes = index.map(_.route).toList,
@@ -61,11 +58,11 @@ object View {
     deserializePage = { (pageStr) =>
       Json.parse(pageStr) match {
         case obj: JsObject =>
-          _currentFilteredIndex
+          _authedIndex(OAuthManager.currentScope)
             .find(_.tag == (obj \ "t").get.as[String])
-            .getOrElse({???})
+            .getOrElse(throw new IllegalArgumentException((obj \ "t").get.as[String]))
             .parseJson(obj.value("v"))
-        case x => println(x);???
+        case x => println(x); throw new IllegalStateException("x is not a JsObject")
       }
     }
   )(
@@ -77,6 +74,7 @@ object View {
     .collectSignal[UsdaImporter.ViewPage] { (page) => UsdaImporter.content(page) }
     .collectSignal[FoodSearch.ViewPage] { (page) => FoodSearch.content(page) }
     .collectSignal[RecipeEdit.ViewPage] { (page) => RecipeEdit.content(page) }
+    .collectSignal[RecipeView.ViewPage] { (page) => RecipeView.content(page) }
 
 }