RecipeEdit.scala 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. package com.weEat.view
  2. import com.raquo.laminar.api.L._
  3. import io.laminext.syntax.core._
  4. import com.raquo.airstream.ownership.ManualOwner
  5. import com.raquo.waypoint._
  6. import play.api.libs.json.{JsValue,Json}
  7. import com.weEat.controllers.{FoodController,USDAController}
  8. import com.weEat.modules._
  9. import com.weEat.shared.models.UnitType._
  10. import com.weEat.shared.models._
  11. import org.scalajs.dom.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
  12. import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
  13. import com.tflucke.typeahead._
  14. import com.tflucke.typeahead.Dataset.Templates
  15. import org.scalajs.dom.document
  16. import gov.usda.nal.fdc.models.DataType._
  17. import scala.concurrent.Future
  18. import scala.util.{Success,Failure}
  19. // TODO: prevent user from not having any of discreet/mass/volume input
  20. // TODO: Save recipe node in cookie until ready to use
  21. object RecipeEdit extends View[Option[String]] {
  22. import com.weEat.Main.headers
  23. implicit val ec = com.weEat.shared.ctx
  24. val navName = "New Recipe"
  25. val tag = "editRecipe"
  26. case class ViewPage(val id: Option[String] = None) extends P {
  27. val title = "Edit Recipe"
  28. def jsonValue = Json.toJson(id)
  29. }
  30. def parseJson(jsVal: JsValue) = ViewPage(jsVal.asOpt[String])
  31. def route = Route.onlyQuery(
  32. encode = (page: ViewPage) => page.id,
  33. decode = (id: Option[String]) => ViewPage(id = id),
  34. pattern = (root / tag / endOfSegments) ? param[String]("id").?
  35. )
  36. def defaultPage = ViewPage()
  37. val ENTER_KEY_CODE = 13
  38. override def permissions = Set("user")
  39. private val _nameIn = input(idAttr := "name",
  40. cls := "form-control",
  41. required := true
  42. )
  43. private val _name: Signal[String] = _nameIn.value
  44. private val internalFoodDS = Dataset(
  45. { _ => Nil},
  46. Some({ (str: String) => FoodController.query(str.toLowerCase)() }),
  47. display = { (food: FoodNodeId) => food.name }
  48. )
  49. private val usdaFoodDS = Dataset(
  50. { _ => Nil},
  51. Some({ str: String =>
  52. USDAController.getFoodsSearch(str, Seq(
  53. Foundation, Survey, SRLegacy
  54. ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
  55. }),
  56. templates = Some(Templates({(x: USDANodeNoId) => x.name}).copy(
  57. header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
  58. val html = document.createElement("div").asInstanceOf[HTMLElement]
  59. html.innerText = "Unoffical Foods"
  60. html
  61. })
  62. )),
  63. display = {(x: USDANodeNoId) => x.name}
  64. )
  65. private val _ingredientSearch = input(typ := "text",
  66. cls := "form-control input-sm",
  67. onMountCallback({(ctx) =>
  68. val elm = ctx.thisNode
  69. TypeaheadElement[FoodNode](
  70. elm.ref.asInstanceOf[HTMLInputElement],
  71. minLength = 3
  72. )(Seq(internalFoodDS, usdaFoodDS))
  73. elm.amend(
  74. TypeaheadRx.onSelected[FoodNode] --> { (e: CursorEvent[FoodNode]) =>
  75. e.selectable.map(_.data).foreach({ node =>
  76. // TODO: default unit
  77. _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
  78. _ingredients.update(_ :+ in)
  79. elm.ref.value = ""
  80. Future.successful(())
  81. }
  82. })
  83. }
  84. )
  85. })
  86. )
  87. private val _stepIn = input(idAttr := "step",
  88. cls := "form-control",
  89. onKeyPress --> { (e: KeyboardEvent) => if (e.keyCode == ENTER_KEY_CODE) {
  90. val elm = e.target.asInstanceOf[HTMLInputElement]
  91. _steps.update(_ :+ elm.value)
  92. elm.value = ""
  93. } }
  94. )
  95. private val _ingredients = Var[Seq[Ingredient]](Nil)
  96. private val _steps = Var[Seq[String]](Nil)
  97. private val _servingsIn = input(idAttr := "serv",
  98. typ := "number",
  99. cls := "form-control",
  100. defaultValue := "4",
  101. stepAttr := "0.1",
  102. required := true
  103. )
  104. private val _servings = _servingsIn.value
  105. private val _discreetIn = input(idAttr := "discreet",
  106. typ := "checkbox",
  107. cls := "form-check-input",
  108. defaultChecked := true
  109. )
  110. private val _discreet = _discreetIn.checked
  111. // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
  112. private val _massPIn = input(idAttr := "massP",
  113. cls := "form-control",
  114. typ := "number",
  115. minAttr := "1",
  116. stepAttr := "0.1",
  117. value := "100"
  118. )
  119. private val _massP = _massPIn.value
  120. // TODO: Try autopopulate ingredients.averge(_.density)
  121. private val _volPIn = input(idAttr := "volP",
  122. cls := "form-control",
  123. typ := "number",
  124. minAttr := "0",
  125. stepAttr := "0.1"
  126. )
  127. private val _volP = _volPIn.value
  128. private val _volPUnit = Var[MeasureUnit](Milliliter)
  129. private val _volPUnitIn = select(cls := "custom-select",
  130. MeasureUnit.units.zipWithIndex
  131. .filter({ case (u, _) => u.typ == VOLUME })
  132. .map({ case (unit, idx) =>
  133. // TODO: default selected dynamic
  134. option(
  135. value := idx.toString,
  136. selected := unit.abr == Milliliter.abr,
  137. unit.abr
  138. )
  139. }),
  140. onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> _volPUnit
  141. )
  142. val recipieNode = _ingredients.signal.combineWithFn(
  143. _steps,
  144. _name,
  145. _discreet,
  146. _servings,
  147. _volP,
  148. _volPUnit,
  149. _massP
  150. ) { case (ingredients, steps, name, discreet, servings, volP, volPUnit,
  151. massP) =>
  152. val gPServ = massP.toFloatOption
  153. val mLPServ = volP.toFloatOption
  154. .map( _ * volPUnit.conversionRatio )
  155. val (servFactor, servUnit) = gPServ.zip(Some(Gram))
  156. .getOrElse(
  157. if (discreet) (1.0f, Count)
  158. else mLPServ.zip(Some(Milliliter)).getOrElse(???)
  159. ).asInstanceOf[(Float, MeasureUnit)]
  160. val numServings = servings.toFloatOption.getOrElse(1.0f)
  161. val stdQtiesPServing = servFactor / servUnit.typ.standardQuanity
  162. val stdQties = numServings * stdQtiesPServing
  163. RecipeNodeNoId(
  164. name,
  165. stdQties,
  166. stdQtiesPServing,
  167. servUnit.typ,
  168. ingredients,
  169. steps,
  170. gPServ.zip(mLPServ).map({ case (m, v) => (m/v).toFloat }),
  171. gPServ
  172. )
  173. }
  174. private def _editIngredient(ing: Ingredient)(callback: (Ingredient => Future[_])) = {
  175. val amount = Var[Float](ing.amount)
  176. val unit = Var[MeasureUnit](Gram)
  177. Overlay.confirmFuture(ing.food.map({ (food) =>
  178. val amountIn = input(typ := "number",
  179. cls := "form-control input-sm col-9",
  180. minAttr := "0",
  181. defaultValue := ing.amount.toString,
  182. onInput.mapToValue.map(_.toFloat) --> amount
  183. )
  184. val unitIn = select(cls := "col-3 custom-select",
  185. MeasureUnit.units.zipWithIndex.map({ case (unit, idx) =>
  186. // TODO: default selected dynamic
  187. option(value := idx.toString,
  188. selected := unit.abr == Gram.abr,
  189. unit.name
  190. )
  191. }),
  192. onChange.mapToValue.map(_.toInt).map(MeasureUnit.units(_)) --> unit
  193. )
  194. div(cls := "row", amountIn, unitIn)
  195. }),
  196. amount.signal.recoverToTry.map(_.isSuccess) &&
  197. unit.signal.recoverToTry.map(_.isSuccess)
  198. ) { () =>
  199. callback(ing.copy(
  200. amount = amount.now(),
  201. unit = unit.now()
  202. ))
  203. }
  204. }
  205. private def _removeFromList[T](s: Seq[T], idx: Int) =
  206. s.take(idx) ++ s.drop(idx + 1)
  207. private val _stepList = ol(
  208. listStyleType := "none",
  209. paddingLeft := "0",
  210. children <-- _steps.signal.map(_.zipWithIndex.map((presentStep _).tupled)),
  211. onMountCallback({(ctx) =>
  212. Sortable.create(ctx.thisNode.ref, SortableOptions.onUpdate({
  213. (event: SortableEvent) =>
  214. _steps.update({ steps =>
  215. (event.oldIndex.toOption, event.newIndex.toOption) match {
  216. case (Some(old), Some(ne)) if (old < ne) =>
  217. moveBackwards(steps, old, ne)
  218. case (Some(old), Some(ne)) =>
  219. moveBackwards(
  220. steps.reverse,
  221. steps.size - old - 1,
  222. steps.size - ne - 1
  223. ).reverse
  224. case (None, Some(ne)) =>
  225. val (first, second) = steps.splitAt(ne)
  226. (first :+ event.item.innerText) ++ second
  227. case (Some(old), None) =>
  228. _removeFromList(steps, old)
  229. }
  230. })
  231. // If the added element is still in the parent, remove it since the
  232. // Rx.update will have generated a new one.
  233. Option(event.item.parentElement).map(_.removeChild(event.item))
  234. def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
  235. (steps.take(old) ++
  236. steps.take(ne+1).drop(old + 1) :+
  237. steps(old)) ++
  238. steps.drop(ne+1)
  239. }))
  240. })
  241. )
  242. def presentFoodNode(idx: Int)(ingredientSig: Signal[Ingredient]) =
  243. li(children <-- ingredientSig.flatMap { (ingredient: Ingredient) =>
  244. Signal.fromFuture(ingredient.food).optionMap { (food) =>
  245. Seq(
  246. span(cls := "ui-icon ui-icon-pencil",
  247. onClick --> { (e: Event) =>
  248. _editIngredient(ingredient) { (in) =>
  249. _ingredients.update(_.updated(idx, in))
  250. Future.successful(())
  251. }
  252. }
  253. ),
  254. span(cls := "ui-icon ui-icon-close",
  255. onClick --> { (e: Event) =>
  256. _ingredients.update(_.filterNot(_ == ingredient))
  257. }
  258. ),
  259. span(f"${ingredient.amount}%.02f${ingredient.unit.abr} ${food.name}")
  260. )
  261. } withDefault(Nil)
  262. })
  263. def presentStep(step: String, idx: Int) = li(
  264. span(cls := "ui-icon ui-icon-close",
  265. onClick --> { (e: Event) =>
  266. _steps.update(_removeFromList(_, idx))
  267. }
  268. ),
  269. step
  270. )
  271. def content(page: Signal[ViewPage]) = div(
  272. h2("Recipe Editor"),
  273. div(cls := "form-group",
  274. div(cls := "container",
  275. div(cls := "row",
  276. div(cls := "col-md-12",
  277. label("Name: "),
  278. _nameIn
  279. )
  280. ),
  281. div(cls := "row",
  282. div(cls := "col-md-3 form-check",
  283. div(cls := "form-check",
  284. _discreetIn,
  285. label(cls := "form-check-label",
  286. forId := "discreet",
  287. "Descrete Servings"
  288. )
  289. )
  290. ),
  291. div(cls := "col-md-3 input-group",
  292. _servingsIn,
  293. div(cls := "input-group-append",
  294. label(cls := "input-group-text", "Servings")
  295. )
  296. ),
  297. div(cls := "col-md-3 input-group",
  298. _massPIn,
  299. div(cls := "input-group-append",
  300. label(cls := "input-group-text", "g/Serving")
  301. )
  302. ),
  303. div(cls := "col-md-3 input-group",
  304. _volPIn,
  305. _volPUnitIn,
  306. div(cls := "input-group-append",
  307. label(cls := "input-group-text", "/Serving"),
  308. ),
  309. )
  310. ),
  311. div(cls := "row",
  312. div(cls := "col-md-5",
  313. h2("Ingredients"),
  314. _ingredientSearch,
  315. ul(
  316. listStyleType := "none",
  317. paddingLeft := "0",
  318. children <-- _ingredients.signal.splitByIndex {
  319. case (idx, _, ingredientStream) =>
  320. presentFoodNode(idx)(ingredientStream)
  321. }
  322. )
  323. ),
  324. div(cls := "col-md-5",
  325. h2("Steps"),
  326. _stepIn,
  327. _stepList
  328. ),
  329. div(cls := "col-md-2",
  330. NutritionPane(recipieNode).render
  331. )
  332. )
  333. ),
  334. button(cls := "btn",
  335. onClick --> { (e: Event) =>
  336. implicit val owner = new ManualOwner()
  337. // import play.api.libs.json.Json
  338. // println(recipieNode.value)
  339. // println(Json.toJson(recipieNode.value))
  340. // println(Json.stringify(Json.toJson(recipieNode.value)))
  341. FoodController.add()(recipieNode.observe.now()).onComplete {
  342. case Success(_) => println("Success!")
  343. case Failure(ex) =>
  344. println("Could not add recipe")
  345. throw ex
  346. }
  347. },
  348. "Add"
  349. )
  350. )
  351. )
  352. }