Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inject store into action handler #127

Merged
merged 4 commits into from
May 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 47 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
# ff4s

![Maven Central](https://img.shields.io/maven-central/v/io.github.buntec/ff4s_sjs1_2.13)

A minimal purely-functional web UI framework for [Scala.js](https://www.scala-js.org/).

Thanks to amazing work by [@yurique](https://github.com/yurique), you can now [try it from your browser](https://scribble.ninja/).
Thanks to amazing work by [@yurique](https://github.com/yurique),
you can now [try it from your browser](https://scribble.ninja/).

Based on these wonderful libraries:
- [Cats](https://typelevel.org/cats/)
- [Cats-Effect](https://typelevel.org/cats-effect/)
- [FS2](https://fs2.io/)
- [fs2-dom](https://github.com/armanbilge/fs2-dom)
- [http4s](https://http4s.org/)
- [Scala DOM Types](https://github.com/raquo/scala-dom-types)
- [Snabbdom](https://github.com/snabbdom/snabbdom) (actually the Scala.js port [scala-js-snabbdom](https://github.com/buntec/scala-js-snabbdom))

- [Cats](https://typelevel.org/cats/)
- [Cats-Effect](https://typelevel.org/cats-effect/)
- [FS2](https://fs2.io/)
- [fs2-dom](https://github.com/armanbilge/fs2-dom)
- [http4s](https://http4s.org/)
- [Scala DOM Types](https://github.com/raquo/scala-dom-types)
- [Snabbdom](https://github.com/snabbdom/snabbdom) (actually the Scala.js port [scala-js-snabbdom](https://github.com/buntec/scala-js-snabbdom))

Inspired by:
- [Outwatch](https://github.com/outwatch/outwatch)
- [Laminar](https://github.com/raquo/Laminar)
- [Calico](https://github.com/armanbilge/calico)

- [Outwatch](https://github.com/outwatch/outwatch)
- [Laminar](https://github.com/raquo/Laminar)
- [Calico](https://github.com/armanbilge/calico)

See the `examples` folder for commented code examples.

Expand All @@ -38,10 +41,11 @@ libraryDependencies += "io.github.buntec" %%% "ff4s" % "<x.y.z>"

The programming model of ff4s is inspired by Elm and Flux/Redux.
The view (what is rendered to the DOM) is a pure function of the state.
State is global, immutable and can be updated only through actions dispatched to the
store (e.g., by clicking a button).
State is global, immutable and can be updated only through actions
dispatched to the store (e.g., by clicking a button).
There is a single store that encapsulates all logic for updating state.
Actions can trigger side-effects (e.g., making a REST call or sending a WebSocket message).
Actions can trigger side-effects
(e.g., making a REST call or sending a WebSocket message).

To illustrate this with an example, let's implement the "Hello, World!" of UIs:
A counter that can be incremented or decremented by clicking a button.
Expand All @@ -66,16 +70,18 @@ case class Reset() extends Action
With the `State` and `Action` types in hand, we can set up our store:

```scala
import cats.effect._
import cats.syntax.all._

val store = Resource[F, ff4s.Store[F, State, Action]] =
ff4s.Store[F, State, Action](State()) {
_ match {
case Inc(amount) =>
state => state.copy(counter = state.counter + amount) -> none
case Reset() => _.copy(counter = 0) -> none
}
val store: Resource[F, ff4s.Store[F, State, Action]] =
ff4s.Store[F, State, Action](State()) { _ =>
_ match {
case Inc(amount) =>
state => state.copy(counter = state.counter + amount) -> none
case Reset() => _.copy(counter = 0) -> none
}
}

```

The purpose of `none` will become clear when looking at more complex examples
Expand All @@ -91,30 +97,30 @@ Finally, we describe how our page should be rendered using the built-in DSL
for HTML markup:

```scala
import dsl._ // `dsl` is provided by `ff4s.App`, see below
import dsl._ // provided by `ff4s.App`, see below
import dsl.html._

val view = useState { state =>
div(
cls := "m-2 flex flex-col items-center", // tailwindcss classes
h1("A counter"),
div(s"value: ${state.counter}"),
button(
cls := "m-1 p-1 border",
"increment",
onClick := (_ => Some(Inc(1)))
),
button(
cls := "m-1 p-1 border",
"decrement",
onClick := (_ => Some(Inc(-1)))
),
button(
cls := "m-1 p-1 border",
"reset",
onClick := (_ => Some(Reset()))
)
div(
cls := "m-2 flex flex-col items-center", // tailwindcss classes
h1("A counter"),
div(s"value: ${state.counter}"),
button(
cls := "m-1 p-1 border",
"increment",
onClick := (_ => Some(Inc(1)))
),
button(
cls := "m-1 p-1 border",
"decrement",
onClick := (_ => Some(Inc(-1)))
),
button(
cls := "m-1 p-1 border",
"reset",
onClick := (_ => Some(Reset()))
)
)
}
```

Expand Down
2 changes: 1 addition & 1 deletion examples/src/main/scala/examples/example1/Store.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import cats.syntax.all._

object Store {

def apply[F[_]: Concurrent] = ff4s.Store[F, State, Action](State()) {
def apply[F[_]: Concurrent] = ff4s.Store[F, State, Action](State()) { _ =>
_ match {
case Action.AddTodo =>
state => {
Expand Down
6 changes: 3 additions & 3 deletions examples/src/main/scala/examples/example2/Store.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ object Store {
// send queue for the websockets connection
wsSendQ <- Queue.bounded[F, String](100).toResource

store <- ff4s.Store[F, State, Action](State()) {
store <- ff4s.Store[F, State, Action](State()) { store =>
_ match {
case Action.WebsocketMessageReceived(msg) =>
_.copy(websocketResponse = msg.some) -> none

case Action.SendWebsocketMessage(msg) =>
_ -> (wsSendQ.offer(msg).as(none[Action])).some
_ -> (wsSendQ.offer(msg)).some

case Action.SetSvgCoords(x, y) =>
_.copy(svgCoords = SvgCoords(x, y)) -> none
Expand Down Expand Up @@ -68,7 +68,7 @@ object Store {
_ -> ff4s
.HttpClient[F]
.get[Bored]("http://www.boredapi.com/api/activity")
.map(activity => (Action.SetActivity(activity): Action).some)
.flatMap(activity => store.dispatch(Action.SetActivity(activity)))
.some
}
}
Expand Down
2 changes: 1 addition & 1 deletion examples/src/main/scala/examples/example3/App.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import monocle.syntax.all._
class App[F[_]](implicit val F: Concurrent[F])
extends ff4s.App[F, State, Action] {

override val store = ff4s.Store[F, State, Action](State()) {
override val store = ff4s.Store[F, State, Action](State()) { _ =>
_ match {
case Action.SetWeekday(weekday) =>
_.focus(_.weekday).replace(weekday) -> none
Expand Down
4 changes: 2 additions & 2 deletions examples/src/main/scala/examples/example4/App.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ class App[F[_]](implicit val F: Async[F]) extends ff4s.App[F, State, Action] {

router <- Router[F](window)

store <- ff4s.Store[F, State, Action](State())(
store <- ff4s.Store[F, State, Action](State())(_ =>
_ match {
case Action.NavigateTo(uri) =>
(_, router.navigateTo(uri).as(none[Action]).some)
(_, router.navigateTo(uri).some)
case Action.SetUri(uri) => _.copy(uri = Some(uri)) -> none
}
)
Expand Down
2 changes: 1 addition & 1 deletion examples/src/main/scala/examples/example5/App.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ case class Inc(amount: Int) extends Action
class App[F[_]](implicit F: Temporal[F]) extends ff4s.App[F, State, Action] {

override val store = ff4s
.Store[F, State, Action](State()) {
.Store[F, State, Action](State()) { _ =>
_ match {
case Inc(amount) =>
state => state.copy(counter = state.counter + amount) -> none
Expand Down
15 changes: 4 additions & 11 deletions examples/src/main/scala/examples/example6/App.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package examples.example6
import cats.effect.Temporal
import cats.effect.implicits._
import cats.effect.kernel.Fiber
import cats.effect.kernel.Outcome.Succeeded
import cats.effect.std.MapRef
import cats.effect.std.Supervisor
import cats.syntax.all._
Expand Down Expand Up @@ -53,10 +52,10 @@ class App[F[_]](implicit F: Temporal[F]) extends ff4s.App[F, State, Action] {

// we keep the fibers of running actions in a map indexed by the cancellation key.
fibers <- MapRef
.ofSingleImmutableMap[F, String, Fiber[F, Throwable, Option[Action]]]()
.ofSingleImmutableMap[F, String, Fiber[F, Throwable, Unit]]()
.toResource

store <- ff4s.Store[F, State, Action](State()) {
store <- ff4s.Store[F, State, Action](State()) { store =>
_ match {
case Inc(amount) =>
state => state.copy(counter = state.counter + amount) -> none
Expand All @@ -66,16 +65,11 @@ class App[F[_]](implicit F: Temporal[F]) extends ff4s.App[F, State, Action] {
(
_,
supervisor
.supervise(F.sleep(delay).as((Inc(amount): Action).some))
.supervise(F.sleep(delay) *> store.dispatch(Inc(amount)))
.flatMap { fiber =>
fibers
.getAndSetKeyValue(cancelKey, fiber)
.flatMap(_.foldMapM(_.cancel)) >> fiber.join.flatMap {
_ match {
case Succeeded(fa) => fa
case _ => none[Action].pure[F]
}
}
.flatMap(_.foldMapM(_.cancel))
}
.some
)
Expand All @@ -85,7 +79,6 @@ class App[F[_]](implicit F: Temporal[F]) extends ff4s.App[F, State, Action] {
_,
fibers(cancelKey).get
.flatMap(_.foldMapM(_.cancel))
.as(none[Action])
.some
)
}
Expand Down
67 changes: 67 additions & 0 deletions examples/src/main/scala/examples/example7/App.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2022 buntec
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package examples.example7

import cats.effect._
import cats.syntax.all._

// This is the example from the readme

final case class State(counter: Int = 0)

sealed trait Action
case class Inc(amount: Int) extends Action
case class Reset() extends Action

class App[F[_]](implicit F: Concurrent[F]) extends ff4s.App[F, State, Action] {

override val store: Resource[F, ff4s.Store[F, State, Action]] =
ff4s.Store[F, State, Action](State()) { _ =>
_ match {
case Inc(amount) =>
state => state.copy(counter = state.counter + amount) -> none
case Reset() => _.copy(counter = 0) -> none
}
}

import dsl._ // provided by `ff4s.App`, see below
import dsl.html._

override val view = useState { state =>
div(
cls := "m-2 flex flex-col items-center", // tailwindcss classes
h1("A counter"),
div(s"value: ${state.counter}"),
button(
cls := "m-1 p-1 border",
"increment",
onClick := (_ => Some(Inc(1)))
),
button(
cls := "m-1 p-1 border",
"decrement",
onClick := (_ => Some(Inc(-1)))
),
button(
cls := "m-1 p-1 border",
"reset",
onClick := (_ => Some(Reset()))
)
)
}

}
29 changes: 16 additions & 13 deletions ff4s/src/main/scala/ff4s/Store.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,38 @@ trait Store[F[_], State, Action] {
object Store {

def apply[F[_]: Concurrent, State, Action](init: State)(
update: Action => State => (State, Option[F[Option[Action]]])
mkUpdate: Store[F, State, Action] => Action => State => (
State,
Option[F[Unit]]
)
): Resource[F, Store[F, State, Action]] = for {
supervisor <- Supervisor[F]

actionQ <- Queue.unbounded[F, Action].toResource

stateSR <- SignallingRef.of[F, State](init).toResource

store = new Store[F, State, Action] {

override def dispatch(action: Action): F[Unit] = actionQ.offer(action)

override def state: Signal[F, State] = stateSR

}

update = mkUpdate(store)

_ <- Stream
.fromQueueUnterminated(actionQ)
.evalMap(action =>
stateSR
.modify(update(action))
.flatMap(
_.foldMapM(foa =>
supervisor.supervise(foa.flatMap(_.foldMapM(actionQ.offer))).void
)
)
.flatMap(_.foldMapM(supervisor.supervise(_).void))
)
.compile
.drain
.background

} yield (new Store[F, State, Action] {

override def dispatch(action: Action): F[Unit] = actionQ.offer(action)

override def state: Signal[F, State] = stateSR

})
} yield store

}
2 changes: 1 addition & 1 deletion todo-mvc/src/main/scala/todomvc/App.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import org.scalajs.dom
class App[F[_]](implicit val F: Concurrent[F])
extends ff4s.App[F, State, Action] {

override val store = ff4s.Store[F, State, Action](State()) {
override val store = ff4s.Store[F, State, Action](State()) { _ =>
_ match {
case Action.SetFilter(filter) => _.copy(filter = filter) -> none

Expand Down