/scalajs-mithril

Scala.js facades for Mithril.js

Primary LanguageScalaMIT LicenseMIT

Scala.js facades for Mithril.js

Build Status

This is an experimental library that provides facades for Mithril.

At the moment, scalajs-mithril is being rewritten to support mithril 1.1.1. The 0.1.0 version of scalajs-mithril for mithril 0.2.5 can be found here.

Mithril 1.x.y is significantly different from 0.2.0, which is why this rewrite is required.

Table of Contents

Setup

Add scalajs-bundler to project/plugins.sbt:

addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.7.0")

Then, add the following lines to build.sbt:

resolvers += Resolver.sonatypeRepo("snapshots")
libraryDependencies += "co.technius" %%% "scalajs-mithril" % "0.2.0-SNAPSHOT"
enablePlugins(ScalaJSBundlerPlugin)

// Change mithril version to any version supported by this library
npmDependencies in Compile += "mithril" -> "1.1.1"

Build your project with fastOptJS::webpack.

Example

import co.technius.scalajs.mithril._
import scala.scalajs.js
import org.scalajs.dom

object MyModule {
  val component = Component.stateful[State, js.Object](_ => new State) { vnode =>
    import vnode.state
    m("div", js.Array[VNode](
      m("span", s"Hi, ${state.name()}!"),
      m("input[type=text]", js.Dynamic.literal(
        oninput = m.withAttr("value", state.name),
        value = state.name()
      ))
    ))
  }

  class State {
    val name = MithrilStream("Name")
  }
}

object MyApp extends js.JSApp {
  def main(): Unit = {
    m.mount(dom.document.getElementById("app"), MyModule.component)
  }
}
<!DOCTYPE HTML>
<html>
  <head>
    <title>scalajs-mithril Example</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="example-fastopt-bundle.js"></script>
  </body>
</html>

See the examples folder for complete examples.

The Basics

This section assumes you are familiar with mithril. If you aren't, don't worry; mithril can be picked up very quickly.

A component is parametized on State (for vnode.state) and Attrs (for vnode.attrs). If State and Attrs are not neccessary for the component, use js.Object and js.Dictionary[js.Any] should be used instead, respectively.

There are two ways to define a component using this library:

  1. Use one of the component helpers, such as Component.stateful or Component.viewOnly.
  2. Subclass Component.

While the helpers provide limited control over components, they are sufficiently powerful for most situations. If more control over a component is desired (e.g. overriding lifecycle methods), then subclass Component instead.

Virtual DOM nodes (vnodes) are defined as GenericVNode[State, Attr]. For convenience, a type alias VNode is defined as GenericVNode[js.Object, js.Dictionary[js.Any]] to reduce the hassle of adding type signatures for vnodes.

Using the Helpers

Defining a stateless component is very simple using the Component.viewOnly function:

import co.technius.scalajs.mithril._
import scala.scalajs.js

object HelloApp extends js.JSApp {
  // viewOnly has the view function as an argument
  val component = Component.viewOnly[js.Object] { vnode =>
    m("div", "Hello world!")
  }
  
  def main(): Unit = {
    m.mount(dom.document.getElementById("app"), component)
  }
}

viewOnly is parameterized on Attr, so it's possible to handle arguments:

// First, create a class to represent the attriute object
case class Attr(name: String)

// Then, supply it as a type parameter to viewOnly
val component = Component.viewOnly[Attr] { vnode =>
  m("div", "Hello " + vnode.attr.name)
}

It's more common to see stateful components, though. They can be defined using Component.stateful.

import co.technius.scalajs.mithril._
import scala.scalajs.js

object NameApp extends js.JSApp {

  // Just like for attributes, define a state class to hold the state
  protected class State {
    var name = "Name"
  }

  // stateful has two arguments:
  // - A function to create the state from a vnode
  // - The view function
  // It's also parameterized on state and attributes
  val component = Component.stateful[State, js.Object](_ => new State) { vnode =>
    import vnode.state
    m("div", js.Array[VNode](
      m("span", s"Hi, ${state.name}!"),
      m("input[type=text]", js.Dynamic.literal(
        oninput = m.withAttr("value", newName => state.name = newName),
        value = state.name
      ))
    ))
  }
  
  def main(): Unit = {
    m.mount(dom.document.getElementById("app"), component)
  }
}

Subclassing Component

Subclassing component requires more boilerplate, but it gives more fine-grained control over the component's lifecycle.

First, you'll need to define your component, which is parametized on State (for vnode.state) and Attrs (for vnode.attrs). If State and Attrs are not neccessary for the component, use js.Object.

object MyComponent extends Component[js.Object, js.Object] {
  // RootNode is defined as an alias
  override val view = (vnode: RootNode) => {
    m("div", js.Array(
      m("p", "Hello world!")
      m("p", "How fantastic!")
    ))
  }
}

Component is a subtrait of Lifecycle, which defines the lifecycle methods. Thus, it's possible to override the lifecycle methods in a Component. Here's an example of a stateful component that overrides oninit to set the state:

object MyComponent extends Component[MyComponentState, js.Object] {
  override val oninit = js.defined { (vnode: RootNode) =>
    vnode.state = new MyComponentState
  }

  override val view = { vnode: RootNode =>
    import vnode.state
    m("div", js.Array(
      m("span", s"Hey there, ${state.name()}!"),
      m("input[type=text]", js.Dynamic.literal(
        oninput = m.withAttr("value", state.name),
        value = ctrl.name()
      ))
    ))
  }
}

class MyComponentState {
  val name = MithrilStream("Name")
}

Due to the way mithril handles the fields in the component, runtime errors occur if methods or functions are defined directly in the component from Scala.js. One possible workaround is to define the functions in an inner object:

object MyComponent extends Component[MyComponentState, js.Object] {
  override val oninit = js.defined { (vnode: RootNode) =>
    vnode.state = new MyComponentState
  }

  override val view = { (vnode: RootNode) =>
    import helpers._
    myFunction(vnode.state)
    /* other code omitted */
  }
  
  object helpers {
    def myFunction(state: MyComponentState): Unit = {
      // do stuff
    }
  }
}

class MyComponentState { /* contents omitted */ }

Lastly, call m.mount with your controller:

import co.technius.scalajs.mithril._
import org.scalajs.dom
import scala.scalajs.js

object MyApp extends js.JSApp {
  def main(): Unit = {
    m.mount(dom.document.getElementById("app"), MyComponent)
  }
}

To use Attrs in a component, define a class for Attrs and change the parameter on Component. The component should then be created by calling m(component, attrs) (see the TreeComponent example).

import co.technius.scalajs.mithril._

case class MyAttrs(name: String)

object MyComponent extends Component[js.Object, MyAttrs] {
  override val view =  { (vnode: RootNode) =>
    m("span", vnode.attrs.name)
  }
}

Routing

To use Mithril's routing functionality, use m.route as it is defined in mithril:

import co.technius.scalajs.mithril._
import org.scalajs.dom
import scala.scalajs.js

object MyApp extends js.JSApp {
  val homeComponent = Component.viewOnly[js.Object] { vnode =>
    m("div", "This is the home page")
  }

  val pageComponent = Component.viewOnly[js.Object] { vnode =>
    m("div", "This is another page")
  }

  def main(): Unit = {
    val routes = js.Dictionary[MithrilRoute.Route](
      "/" -> homeComponent,
      "/page" -> pageComponent
    )
    m.route(dom.document.getElementById("app"), "/", routes)
  }
}

For convenience, there is an alias for m.route that accepts a vararg list of routes instead of a js.Dictionary:

m.route(dom.document.getElementById("app"), "/", routes)(
  "/" -> homeComponent,
  "/page" -> pageComponent
)

A RouteResolver may be used for more complicated routing situations. There are two ways to construct a RouteResolver: using a helper method or subclassing RouteResolver.

RouteResolver.render creates a RouteResolver with the given render function.

m.route(dom.document.getElementById("app"), "/", routes)(
  "/" -> RouteResolver.render { vnode =>
    m("div", js.Array[VNode](
      m("h1", "Home component"),
      homeComponent
    ))
  },
  "/page" -> pageComponent
)

Similarly, RouteResolver.onmatch creates a RouteResolver with the given onmatch function.

val accessDeniedComponent = Component.viewOnly[js.Object] { vnode =>
  m("div", "Incorrect or missing password!")
}

val secretComponent = Component.viewOnly[js.Object] { vnode =>
  m("div", "Welcome to the secret page!")
}

m.route(dom.document.getElementById("app"), "/", routes)(
  "/secret" -> RouteResolver.onmatch { (params, requestedPath) =>
    // check if password is correct
    if (params.get("password").fold(false)(_ == "12345")) {
      secretComponent
    } else {
      accessDeniedComponent
    }
  }
)

If it is required to define both render and onmatch, subclass RouteResolver. Note that this library always ensures that render is defined, but allows onmatch to be undefined.

val helloComponent = Component.viewOnly[js.Object](_ => m("div", "Hello world!"))

val myRouteResolver = new RouteResolver {
  override def onmatch = js.defined { (params, requestedPath) =>
    helloComponent
  }

  override def render = { vnode =>
    vnode
  }
}

Making Web Requests

First, create an XHROptions[T], where T is the data to be returned:

val opts = new XHROptions[js.Object](method = "GET", url = "/path/to/request")

It's possible to use most of the optional arguments:

val opts =
  new XHROptions[js.Object](
    method = "POST",
    url = "/path/to/request",
    data = js.Dynamic.literal("foo" -> 1, "bar" -> 2),
    background = true)

Then, pass the options to m.request, which will return a js.Promise[T]:

val reqPromise = m.request(opts)

// convert Promise[T] to Future[T]
// use of Future requires implicit ExecutionContext
import scala.concurrent.ExecutionContext.Implicits.global
reqPromise.toFuture.foreach { data =>
  println(data)
}

By default, the response data will be returned as a js.Object. It may be convenient to define a facade to hold the response data:

// Based on examples/src/main/resources/sample-data.json
import scala.concurrent.ExecutionContext.Implicits.global
@js.native
trait MyData extends js.Object {
  val key: String
  val some_number: Int
}

val opts = new XHROptions[MyData](method = "GET", url = "/path/to/request")

m.request(opts).toFuture foreach { data =>
  println(data.key)
  println(data.some_number)
}

Scalatags Support

There is also support for Scalatags, which can make it easier to create views. Add the following line to build.sbt:

libraryDependencies += "co.technius" %%% "scalajs-mithril-scalatags" % "0.2.0-SNAPSHOT"

Then, import co.technius.scalajs.mithril.VNodeScalatags.all._. If you already imported the mithril package as a wildcard, simply import VNodeScalatags.all._. You can then use tags in your components:

val component = Component.viewOnly[js.Object] { vnode =>
  div(id := "my-div")(
    p("Hello world!")
  ).render
}

It's also possible to use VNodes with scalatags:

// Components are also VNodes
val embeddedComponent = Component.viewOnly[js.Object] { vnode =>
  p("My embedded component").render
}

val component = Component.viewOnly[js.Object] { vnode =>
  div(
    m("p", "My root component"),
    embeddedComponent
  ).render
}

The lifecycle methods, as well as key, are usable as attributes in scalatags:

class Attrs(items: scala.collection.mutable.Map[String, String])

val component = Component.viewOnly[Attrs] { vnode =>
  ul(oninit := { () => println("Initialized!") })(
    vnode.attrs.items.map {
      case (id, name) => li(key := id, name)
    }
  ).render
}

See the scalatags demo for an example.

Compiling

  • Compile the core project with core/compile.
  • Examples can be built locally by running examples/fastOptJS::webpack and then navigating to examples/src/main/resources/index.html.
  • The benchmarks are built in the same manner as the examples. benchmarks/fastOptJS::webpack
  • To run tests, use tests/test.

License

This library is licensed under the MIT License. See LICENSE for more details.