RecipeVar.scala 13 KB

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