From f62dd4f1ae0c08c97421c64f42dff91d1ccec2d1 Mon Sep 17 00:00:00 2001 From: roelvanderpaal Date: Sat, 24 Feb 2024 16:22:11 +0100 Subject: [PATCH 1/5] - HTML as defined in https://github.com/tastejs/todomvc-app-template - implement full app specification: https://github.com/tastejs/todomvc/blob/master/app-spec.md - toggle-all button - editing: - trim text - remove when empty - undo on escape key - clear-completed button --- todo-mvc/src/main/scala/todomvc/TodoMvc.scala | 158 +++++++++++++----- 1 file changed, 112 insertions(+), 46 deletions(-) diff --git a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala index e6d19eeb..5693745d 100644 --- a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala +++ b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala @@ -17,18 +17,17 @@ package todomvc import calico.* -import calico.frp.* -import calico.frp.given -import calico.html.io.* -import calico.html.io.given +import calico.frp.{*, given} +import calico.html.io.{*, given} import calico.router.* import cats.data.* import cats.effect.* import cats.syntax.all.* import fs2.concurrent.* import fs2.dom.* -import io.circe.Codec -import io.circe.jawn +import io.circe +import io.circe.Decoder.Result +import io.circe.* import io.circe.syntax.* import org.http4s.* import org.scalajs.dom.KeyValue @@ -46,9 +45,17 @@ object TodoMvc extends IOWebApp: } { filter => div( cls := "todoapp", - div(cls := "header", h1("todos"), TodoInput(store)), - div( + headerTag(cls := "header", h1("todos"), TodoInput(store)), + sectionTag( cls := "main", + input.withSelf(self => + ( + idAttr := "toggle-all", + cls := "toggle-all", + typ := "checkbox", + checked <-- store.allCompleted, + onInput --> { _.foreach(_ => self.checked.get.flatMap(store.toggleAll)) })), + label(forId := "toggle-all", "Mark all as complete"), ul( cls := "todo-list", children[Long](id => TodoItem(store.entry(id))) <-- filter.flatMap(store.ids(_)) @@ -58,7 +65,7 @@ object TodoMvc extends IOWebApp: .size .map(_ > 0) .changes - .map(if _ then StatusBar(store.activeCount, filter, router).some else None) + .map(if _ then StatusBar(store, filter, router).some else None) ) } } @@ -92,22 +99,36 @@ object TodoMvc extends IOWebApp: case true => List( input.withSelf { self => - val endEdit = self.value.get.flatMap { text => - todo.update(_.map(_.copy(text = text))) *> editing.set(false) - } - + val endEdit = self.value.get.map(_.trim).flatMap { text => + todo.update(t => + text match { + case "" => None + case _ => t.map(_.copy(text = text.trim)) + }) + } *> editing.set(false) ( cls := "edit", defaultValue <-- todo.map(_.foldMap(_.text)), onKeyDown --> { - _.filter(_.key == KeyValue.Enter).foreach(_ => endEdit) + _.foreach { + case e if e.key == KeyValue.Enter => endEdit + case e if e.key == KeyValue.Escape => editing.set(false) + case _ => IO.unit + } }, - onBlur --> (_.foreach(_ => endEdit)) + onBlur --> (_.foreach(_ => { + editing + .get + .flatMap( + IO.whenA(_)(endEdit) + ) // do not endEdit when blur is triggered after Escape + })) ) } ) case false => - List( + List(div( + cls := "view", input.withSelf { self => ( cls := "toggle", @@ -124,13 +145,13 @@ object TodoMvc extends IOWebApp: }, label(todo.map(_.map(_.text))), button(cls := "destroy", onClick --> (_.foreach(_ => todo.set(None)))) - ) + )) } ) } def StatusBar( - activeCount: Signal[IO, Int], + store: TodoStore, filter: Signal[IO, Filter], router: Router[IO] ): Resource[IO, HtmlElement[IO]] = @@ -138,29 +159,47 @@ object TodoMvc extends IOWebApp: cls := "footer", span( cls := "todo-count", - activeCount.map { - case 1 => "1 item left" - case n => n.toString + " items left" + strong(store.activeCount.map(_.toString)), + store.activeCount.map { + case 1 => " item left" + case n => " items left" } ), ul( cls := "filters", - Filter - .values - .toList - .map { f => - li( - a( - cls <-- filter.map(_ == f).map(Option.when(_)("selected").toList), - onClick --> (_.foreach(_ => router.navigate(Uri(fragment = f.fragment.some)))), - f.toString - ) + Filter.values.toList.map { f => + li( + a( + cls <-- filter.map(_ == f).map(Option.when(_)("selected").toList), + onClick --> (_.foreach(_ => router.navigate(Uri(fragment = f.fragment.some)))), + href := s"/#${f.fragment}", + f.toString ) - } - ) + ) + } + ), + store + .hasCompleted + .map( + Option.when(_)( + button( + cls := "clear-completed", + onClick --> { + _.foreach(_ => store.clearCompleted) + }, + "Clear completed"))) ) class TodoStore(entries: SignallingSortedMapRef[IO, Long, Todo], nextId: IO[Long]): + def toggleAll(completed: Boolean): IO[Unit] = + entries.update(_.map((id, todo) => (id, todo.copy(completed = completed)))) + + def allCompleted: Signal[IO, Boolean] = entries.map(_.values.forall(_.completed)) + + def hasCompleted: Signal[IO, Boolean] = entries.map(_.values.exists(_.completed)) + + def clearCompleted: IO[Unit] = entries.update(_.filterNot((_, todo) => todo.completed)) + def create(text: String): IO[Unit] = nextId.flatMap(entries(_).set(Some(Todo(text, false)))) @@ -178,30 +217,57 @@ object TodoStore: def apply(window: Window[IO]): Resource[IO, TodoStore] = val key = "todos-calico" + implicit val encodeFoo: Encoder[(Long, Todo)] = new Encoder[(Long, Todo)] { + override def apply(a: (Long, Todo)): Json = { + val (id, todo) = a + Json.obj( + ("id", Json.fromLong(id)), + ("title", Json.fromString(todo.text)), + ("completed", Json.fromBoolean(todo.completed)) + ) + } + } + + implicit val decodeFoo: Decoder[(Long, Todo)] = new Decoder[(Long, Todo)] { + override def apply(c: HCursor): Result[(Long, Todo)] = for { + id <- c.downField("id").as[Long] + title <- c.downField("title").as[String] + completed <- c.downField("completed").as[Boolean] + } yield { + (id, Todo(title, completed)) + } + } + for mapRef <- SignallingSortedMapRef[IO, Long, Todo].toResource _ <- Resource.eval { OptionT(window.localStorage.getItem(key)) - .subflatMap(jawn.decode[SortedMap[Long, Todo]](_).toOption) + .subflatMap(circe.jawn.decode[List[(Long, Todo)]](_).toOption.map(SortedMap.from)) .foreachF(mapRef.set(_)) } - _ <- window - .localStorage - .events(window) - .foreach { - case Storage.Event.Updated(`key`, _, value, _) => - jawn.decode[SortedMap[Long, Todo]](value).foldMapM(mapRef.set(_)) - case _ => IO.unit - } - .compile - .drain - .background +// _ <- window +// .localStorage +// .events(window) +// .foreach { +// case Storage.Event.Updated(`key`, _, value, _) => +// jawn.decode[SortedMap[Long, Todo]](value).foldMapM(mapRef.set(_)) +// case _ => IO.unit +// } +// .compile +// .drain +// .background _ <- mapRef .discrete - .foreach(todos => IO.cede *> window.localStorage.setItem(key, todos.asJson.noSpaces)) + .foreach((todos: Map[Long, Todo]) => + IO.cede *> window + .localStorage + .setItem( + key, + todos.toList.asJson.noSpaces + )) .compile .drain .background From dfe99fec3a815b2d6179fd69151fd40e7e5f7da5 Mon Sep 17 00:00:00 2001 From: Roel Van der Paal Date: Sat, 24 Feb 2024 16:39:12 +0100 Subject: [PATCH 2/5] Update todo-mvc/src/main/scala/todomvc/TodoMvc.scala Co-authored-by: Arman Bilge --- todo-mvc/src/main/scala/todomvc/TodoMvc.scala | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala index 5693745d..5f60884e 100644 --- a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala +++ b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala @@ -117,11 +117,8 @@ object TodoMvc extends IOWebApp: } }, onBlur --> (_.foreach(_ => { - editing - .get - .flatMap( - IO.whenA(_)(endEdit) - ) // do not endEdit when blur is triggered after Escape + // do not endEdit when blur is triggered after Escape + editing.get.ifM(endEdit, IO.unit) })) ) } From 4c8735a007df82e514a1f6aef0cde5240d4b4c89 Mon Sep 17 00:00:00 2001 From: roelvanderpaal Date: Sat, 24 Feb 2024 17:01:10 +0100 Subject: [PATCH 3/5] fix comments --- todo-mvc/src/main/scala/todomvc/TodoMvc.scala | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala index 5693745d..ea9e8c43 100644 --- a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala +++ b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala @@ -26,8 +26,8 @@ import cats.syntax.all.* import fs2.concurrent.* import fs2.dom.* import io.circe -import io.circe.Decoder.Result import io.circe.* +import io.circe.Decoder.Result import io.circe.syntax.* import org.http4s.* import org.scalajs.dom.KeyValue @@ -103,7 +103,7 @@ object TodoMvc extends IOWebApp: todo.update(t => text match { case "" => None - case _ => t.map(_.copy(text = text.trim)) + case _ => t.map(_.copy(text = text)) }) } *> editing.set(false) ( @@ -160,10 +160,13 @@ object TodoMvc extends IOWebApp: span( cls := "todo-count", strong(store.activeCount.map(_.toString)), - store.activeCount.map { - case 1 => " item left" - case n => " items left" - } + store + .activeCount + .map { + case 1 => " item left" + case n => " items left" + } + .changes ), ul( cls := "filters", @@ -192,7 +195,7 @@ object TodoMvc extends IOWebApp: class TodoStore(entries: SignallingSortedMapRef[IO, Long, Todo], nextId: IO[Long]): def toggleAll(completed: Boolean): IO[Unit] = - entries.update(_.map((id, todo) => (id, todo.copy(completed = completed)))) + entries.update(sm => SortedMap.from(sm.view.mapValues(_.copy(completed = completed)))) def allCompleted: Signal[IO, Boolean] = entries.map(_.values.forall(_.completed)) @@ -244,20 +247,25 @@ object TodoStore: _ <- Resource.eval { OptionT(window.localStorage.getItem(key)) .subflatMap(circe.jawn.decode[List[(Long, Todo)]](_).toOption.map(SortedMap.from)) - .foreachF(mapRef.set(_)) + .foreachF(mapRef.set) } -// _ <- window -// .localStorage -// .events(window) -// .foreach { -// case Storage.Event.Updated(`key`, _, value, _) => -// jawn.decode[SortedMap[Long, Todo]](value).foldMapM(mapRef.set(_)) -// case _ => IO.unit -// } -// .compile -// .drain -// .background + _ <- window + .localStorage + .events(window) + .foreach { + case Storage.Event.Updated(`key`, _, value, _) => + circe + .jawn + .decode[List[(Long, Todo)]](value) + .toOption + .map(SortedMap.from) + .foldMapM(mapRef.set) + case _ => IO.unit + } + .compile + .drain + .background _ <- mapRef .discrete From 8abfc08d378a1b7ab249cc3a32d9381844389984 Mon Sep 17 00:00:00 2001 From: roelvanderpaal Date: Sat, 24 Feb 2024 17:11:03 +0100 Subject: [PATCH 4/5] scalafix --- todo-mvc/src/main/scala/todomvc/TodoMvc.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala index ab0fbb59..86feea88 100644 --- a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala +++ b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala @@ -26,8 +26,8 @@ import cats.syntax.all.* import fs2.concurrent.* import fs2.dom.* import io.circe -import io.circe.* import io.circe.Decoder.Result +import io.circe.* import io.circe.syntax.* import org.http4s.* import org.scalajs.dom.KeyValue From d5a4cf1f03ef9a2102e3aa443ae5769defaa6fc0 Mon Sep 17 00:00:00 2001 From: Arman Bilge Date: Thu, 29 Feb 2024 16:59:36 +0000 Subject: [PATCH 5/5] Sprinkle `.changes` combinator for efficiency --- todo-mvc/src/main/scala/todomvc/TodoMvc.scala | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala index 86feea88..72d8c75e 100644 --- a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala +++ b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala @@ -157,13 +157,10 @@ object TodoMvc extends IOWebApp: span( cls := "todo-count", strong(store.activeCount.map(_.toString)), - store - .activeCount - .map { - case 1 => " item left" - case n => " items left" - } - .changes + store.activeCount.map { + case 1 => " item left" + case n => " items left" + } ), ul( cls := "filters", @@ -194,9 +191,9 @@ class TodoStore(entries: SignallingSortedMapRef[IO, Long, Todo], nextId: IO[Long def toggleAll(completed: Boolean): IO[Unit] = entries.update(sm => SortedMap.from(sm.view.mapValues(_.copy(completed = completed)))) - def allCompleted: Signal[IO, Boolean] = entries.map(_.values.forall(_.completed)) + def allCompleted: Signal[IO, Boolean] = entries.map(_.values.forall(_.completed)).changes - def hasCompleted: Signal[IO, Boolean] = entries.map(_.values.exists(_.completed)) + def hasCompleted: Signal[IO, Boolean] = entries.map(_.values.exists(_.completed)).changes def clearCompleted: IO[Unit] = entries.update(_.filterNot((_, todo) => todo.completed)) @@ -208,9 +205,9 @@ class TodoStore(entries: SignallingSortedMapRef[IO, Long, Todo], nextId: IO[Long def ids(filter: Filter): Signal[IO, List[Long]] = entries.map(_.filter((_, t) => filter.pred(t)).keySet.toList) - def size: Signal[IO, Int] = entries.map(_.size) + def size: Signal[IO, Int] = entries.map(_.size).changes - def activeCount: Signal[IO, Int] = entries.map(_.values.count(!_.completed)) + def activeCount: Signal[IO, Int] = entries.map(_.values.count(!_.completed)).changes object TodoStore: