├── .gitignore ├── .travis.yml ├── CHANGES.md ├── LICENSE ├── README.md ├── build.sbt ├── client └── src │ ├── main │ └── scala │ │ └── spatutorial │ │ └── client │ │ ├── SPAMain.scala │ │ ├── components │ │ ├── Bootstrap.scala │ │ ├── BootstrapStyles.scala │ │ ├── Chart.scala │ │ ├── GlobalStyles.scala │ │ ├── Icon.scala │ │ ├── JQuery.scala │ │ ├── Motd.scala │ │ ├── TodoList.scala │ │ └── package.scala │ │ ├── logger │ │ ├── Log4JavaScript.scala │ │ ├── LoggerFactory.scala │ │ └── package.scala │ │ ├── modules │ │ ├── Dashboard.scala │ │ ├── MainMenu.scala │ │ └── TODO.scala │ │ ├── package.scala │ │ └── services │ │ ├── AjaxClient.scala │ │ └── SPACircuit.scala │ └── test │ └── scala │ └── spatutorial │ └── client │ └── services │ └── SPACircuitTests.scala ├── doc ├── LANGS.md ├── en │ ├── README.md │ ├── SUMMARY.md │ ├── application-structure.md │ ├── autowire-and-boopickle.md │ ├── css-in-scala.md │ ├── dashboard.md │ ├── debugging.md │ ├── faq.md │ ├── getting-started.md │ ├── images │ │ ├── control-flow.png │ │ ├── dashboard.png │ │ ├── debug1.png │ │ ├── debug2.png │ │ ├── dialogbox.png │ │ ├── dispatcher-actor.png │ │ └── todos.png │ ├── integrating-javascript-components.md │ ├── logging.md │ ├── main-menu.md │ ├── production-build.md │ ├── routing.md │ ├── sbt-build-definition.md │ ├── server-side.md │ ├── testing.md │ ├── the-client.md │ ├── todo-module-and-data-flow.md │ ├── using-resources-from-webjars.md │ └── what-next.md ├── jp │ ├── README.md │ ├── SUMMARY.md │ ├── application-structure.md │ ├── autowire-and-boopickle.md │ ├── css-in-scala.md │ ├── dashboard.md │ ├── debugging.md │ ├── faq.md │ ├── getting-started.md │ ├── images │ │ ├── control-flow.png │ │ ├── dashboard.png │ │ ├── debug1.png │ │ ├── debug2.png │ │ ├── dialogbox.png │ │ ├── dispatcher-actor.png │ │ └── todos.png │ ├── integrating-javascript-components.md │ ├── logging.md │ ├── main-menu.md │ ├── production-build.md │ ├── routing.md │ ├── sbt-build-definition.md │ ├── server-side.md │ ├── testing.md │ ├── the-client.md │ ├── todo-module-and-data-flow.md │ ├── using-resources-from-webjars.md │ └── what-next.md └── kr │ ├── README.md │ ├── SUMMARY.md │ ├── application-structure.md │ ├── autowire-and-boopickle.md │ ├── css-in-scala.md │ ├── dashboard.md │ ├── debugging.md │ ├── faq.md │ ├── getting-started.md │ ├── images │ ├── control-flow.png │ ├── dashboard.png │ ├── debug1.png │ ├── debug2.png │ ├── dialogbox.png │ ├── dispatcher-actor.png │ └── todos.png │ ├── integrating-javascript-components.md │ ├── logging.md │ ├── main-menu.md │ ├── production-build.md │ ├── routing.md │ ├── sbt-build-definition.md │ ├── server-side.md │ ├── testing.md │ ├── the-client.md │ ├── todo-module-and-data-flow.md │ ├── using-resources-from-webjars.md │ └── what-next.md ├── project ├── Settings.scala ├── build.properties └── plugins.sbt ├── publishDocs.sh ├── server └── src │ └── main │ ├── assets │ └── stylesheets │ │ └── main.less │ ├── resources │ ├── application.conf │ └── routes │ ├── scala │ ├── controllers │ │ └── Application.scala │ └── services │ │ └── ApiService.scala │ └── twirl │ └── views │ ├── index.scala.html │ └── tags │ └── _asset.scala.html └── shared └── src └── main └── scala └── spatutorial └── shared ├── Api.scala └── TodoItem.scala /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | node_modules 4 | 5 | # sbt specific 6 | .cache/ 7 | .history/ 8 | .lib/ 9 | dist/* 10 | target/ 11 | lib_managed/ 12 | src_managed/ 13 | project/boot/ 14 | project/plugins/project/ 15 | 16 | # Scala-IDE specific 17 | .scala_dependencies 18 | .worksheet 19 | .cache* 20 | .settings 21 | .project 22 | .classpath 23 | */bin 24 | 25 | # IntelliJ IDEA 26 | .idea 27 | *.iml 28 | out 29 | *.sc 30 | 31 | # GitBook 32 | doc/_book 33 | /_book 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - 2.11.8 4 | sudo: false 5 | jdk: 6 | - oraclejdk8 7 | script: 8 | - sbt ++$TRAVIS_SCALA_VERSION server/test client/test 9 | # Tricks to avoid unnecessary cache updates, from 10 | # http://www.scala-sbt.org/0.13/docs/Travis-CI-with-sbt.html 11 | - find $HOME/.sbt -name "*.lock" | xargs rm 12 | - find $HOME/.ivy2 -name "ivydata-*.properties" | xargs rm 13 | cache: 14 | directories: 15 | - $HOME/.ivy2/cache 16 | - $HOME/.sbt/boot/ 17 | install: 18 | - . $HOME/.nvm/nvm.sh 19 | - nvm install stable 20 | - nvm use stable 21 | - npm install 22 | - npm install jsdom 23 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Change history 2 | 3 | ## 1.1.5 4 | * Update to dependencies 5 | * Scala.js 0.6.18, scalajs-react 1.0.1, React 15.5.4, Diode 1.1.2, BooPickle 1.2.6, ScalaCSS 0.5.3 6 | * Huge thanks go to @ttoman for this update! 7 | 8 | ## 1.1.4 9 | * Updates to dependencies: 10 | * Scala.js 0.6.13 (support for Scala 2.12), scalajs-react 0.11.3, React 15.3.1, Diode 1.1.0, BooPickle 1.2.5, ScalaCSS 0.5.0 11 | 12 | ## 1.1.3 13 | 14 | * Major updates to dependencies: 15 | * Scala 2.11.8, Scala.js 0.6.8, SBT 0.13.11, scalajs-react 0.11.0, React 15.0.1, Play 2.5.1, ScalaCSS 0.4.1, Diode 0.5.1 16 | 17 | ## 1.1.2 18 | 19 | * Updated to Diode 0.5.0, boopickle 1.1.2, scalajs-react 0.10.4 and scalajs-dom 0.9.0 20 | 21 | ## 1.1.0 22 | 23 | * Switched from custom Flux/Rx architecture to [Diode](https://github.com/ochrons/diode) 24 | * Updated to React 0.14 and scalajs-react 0.10.2 25 | 26 | ## 1.0.2 27 | 28 | * Updated build file to support Scala IDE better 29 | 30 | ## 1.0.1 31 | 32 | * Updated to 0.2.7 of `sbt-play-scalajs` to fix issue #20 33 | 34 | ## 1.0.0 35 | 36 | * Server side is now on top of Play instead of Spray 37 | * Simplified build file a lot 38 | * Update to latest versions of libraries 39 | 40 | ## 0.1.10 41 | 42 | * Switched from uPickle to [BooPickle](https://github.com/ochrons/boopickle) 43 | 44 | ## 0.1.9 45 | 46 | * Style definitions are now done with [ScalaCSS](https://github.com/japgolly/scalacss/) 47 | * Documentation is now in a separate [GitBook](http://ochrons.github.io/scalajs-spa-tutorial/) 48 | 49 | ## 0.1.8 50 | 51 | * Upgraded many libraries to their latest versions 52 | * Changed how the MainRouter is initialized and used to make it more convenient 53 | 54 | ## 0.1.7 55 | 56 | * Support for logging on the client side (also delivers log messages to the server!) 57 | * Source maps are served by the web server, to enable debugging with original source files on Chrome 58 | 59 | ## 0.1.6 60 | 61 | * Added production build features 62 | * Updated to Scala.js 0.6.1 and scalajs-react 0.8.1 63 | 64 | ## 0.1.5 65 | 66 | * Cleaner SBT build definition (credits to @PerWiklander) 67 | * Managing JS, CSS and other resources with WebJars 68 | 69 | ## 0.1.4 70 | 71 | * Introduced [ScalaRx](https://github.com/lihaoyi/scala.rx) to propagate changes from store to views, replaced EventEmitter 72 | 73 | ## 0.1.3 74 | 75 | * Unidirectional data flow framework *Ukko* following Facebook Flux and actor architectures 76 | * Todo list implemented with the new data flow model 77 | * Main menu item *Todo* now shows count of open todos 78 | * Testing with *uTest* 79 | 80 | ## 0.1.2 81 | 82 | * Simple jQuery integration added (Bootstrap Modal) 83 | * Modal example with a form 84 | * Refactored Bootstrap components a bit 85 | * Todos are now updated on the server 86 | 87 | ## 0.1.1 88 | 89 | * Refactored the router system to follow the intended design of the Scala.js React router (thanks to @japgolly for feedback) 90 | * Separated MainMenu into its own component as part of the router refactoring 91 | * Updated libraries scalajs-dom and scalajs-react to 0.8.0 92 | * Changed all tags to use <^ prefixes (less potential for name conflicts) 93 | * Documentation updated to reflect the changes 94 | 95 | ## 0.1.0 96 | 97 | * Initial release 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scala.js SPA-tutorial 2 | 3 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/ochrons/scalajs-spa-tutorial?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 4 | [![Scala.js](https://www.scala-js.org/assets/badges/scalajs-0.6.17.svg)](https://www.scala-js.org) 5 | 6 | Tutorial for creating a simple (but potentially complex!) Single Page Application with 7 | [Scala.js](http://www.scala-js.org/) and [Play](https://www.playframework.com/). 8 | 9 | ## Purpose 10 | 11 | This project demonstrates typical design patterns and practices for developing SPAs with Scala.js with special focus on 12 | building a complete application. It started as a way to learn more about Scala.js and related libraries, but then I 13 | decided to make it more tutorial-like for the greater good :) 14 | 15 | The code covers typical aspects of building a SPA using Scala.js but it doesn't try to be an all-encompassing example 16 | for all the things possible with Scala.js. Before going through this tutorial, it would be helpful if you already know 17 | the basics of Scala.js and have read through the official [Scala.js tutorial](http://www.scala-js.org/doc/tutorial.html) 18 | and the great e-book [Hands-on Scala.js](http://lihaoyi.github.io/hands-on-scala-js/#Hands-onScala.js) by 19 | [Li Haoyi (@lihaoyi)](https://github.com/lihaoyi). 20 | 21 | # Documentation 22 | 23 | Tutorial [documentation](https://ochrons.github.io/scalajs-spa-tutorial) is now presented as a GitBook. 24 | 25 | 日本語を話せますか?Scala.js is Big in Japan, so I'm looking for help to translate the tutorial documentation into Japanese. 26 | Contact me on twitter (@ochrons) or via email (otto@chrons.me) if you're interested! お願いします! 27 | 28 | # Scala IDE users 29 | 30 | If you are using Scala IDE, you need to set additional settings to get your Eclipse project exported from SBT. 31 | 32 | ``` 33 | set EclipseKeys.skipParents in ThisBuild := false 34 | eclipse 35 | ``` 36 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbt.Keys._ 2 | import sbt.Project.projectToRef 3 | 4 | // a special crossProject for configuring a JS/JVM/shared structure 5 | lazy val shared = (crossProject.crossType(CrossType.Pure) in file("shared")) 6 | .settings( 7 | scalaVersion := Settings.versions.scala, 8 | libraryDependencies ++= Settings.sharedDependencies.value 9 | ) 10 | // set up settings specific to the JS project 11 | .jsConfigure(_ enablePlugins ScalaJSWeb) 12 | 13 | lazy val sharedJVM = shared.jvm.settings(name := "sharedJVM") 14 | 15 | lazy val sharedJS = shared.js.settings(name := "sharedJS") 16 | 17 | // use eliding to drop some debug code in the production build 18 | lazy val elideOptions = settingKey[Seq[String]]("Set limit for elidable functions") 19 | 20 | // instantiate the JS project for SBT with some additional settings 21 | lazy val client: Project = (project in file("client")) 22 | .settings( 23 | name := "client", 24 | version := Settings.version, 25 | scalaVersion := Settings.versions.scala, 26 | scalacOptions ++= Settings.scalacOptions, 27 | libraryDependencies ++= Settings.scalajsDependencies.value, 28 | // by default we do development build, no eliding 29 | elideOptions := Seq(), 30 | scalacOptions ++= elideOptions.value, 31 | jsDependencies ++= Settings.jsDependencies.value, 32 | // RuntimeDOM is needed for tests 33 | jsDependencies += RuntimeDOM % "test", 34 | // yes, we want to package JS dependencies 35 | skip in packageJSDependencies := false, 36 | // use Scala.js provided launcher code to start the client app 37 | scalaJSUseMainModuleInitializer := true, 38 | scalaJSUseMainModuleInitializer in Test := false, 39 | // use uTest framework for tests 40 | testFrameworks += new TestFramework("utest.runner.Framework") 41 | ) 42 | .enablePlugins(ScalaJSPlugin, ScalaJSWeb) 43 | .dependsOn(sharedJS) 44 | 45 | // Client projects (just one in this case) 46 | lazy val clients = Seq(client) 47 | 48 | // instantiate the JVM project for SBT with some additional settings 49 | lazy val server = (project in file("server")) 50 | .settings( 51 | name := "server", 52 | version := Settings.version, 53 | scalaVersion := Settings.versions.scala, 54 | scalacOptions ++= Settings.scalacOptions, 55 | libraryDependencies ++= Settings.jvmDependencies.value, 56 | commands += ReleaseCmd, 57 | // triggers scalaJSPipeline when using compile or continuous compilation 58 | compile in Compile := ((compile in Compile) dependsOn scalaJSPipeline).value, 59 | // connect to the client project 60 | scalaJSProjects := clients, 61 | pipelineStages in Assets := Seq(scalaJSPipeline), 62 | pipelineStages := Seq(digest, gzip), 63 | // compress CSS 64 | LessKeys.compress in Assets := true 65 | ) 66 | .enablePlugins(PlayScala) 67 | .disablePlugins(PlayLayoutPlugin) // use the standard directory layout instead of Play's custom 68 | .aggregate(clients.map(projectToRef): _*) 69 | .dependsOn(sharedJVM) 70 | 71 | // Command for building a release 72 | lazy val ReleaseCmd = Command.command("release") { 73 | state => "set elideOptions in client := Seq(\"-Xelide-below\", \"WARNING\")" :: 74 | "client/clean" :: 75 | "client/test" :: 76 | "server/clean" :: 77 | "server/test" :: 78 | "server/dist" :: 79 | "set elideOptions in client := Seq()" :: 80 | state 81 | } 82 | 83 | // lazy val root = (project in file(".")).aggregate(client, server) 84 | 85 | // loads the Play server project at sbt startup 86 | onLoad in Global := (Command.process("project server", _: State)) compose (onLoad in Global).value 87 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/SPAMain.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client 2 | 3 | import japgolly.scalajs.react.extra.router._ 4 | import japgolly.scalajs.react.vdom.html_<^._ 5 | import org.scalajs.dom 6 | import spatutorial.client.components.GlobalStyles 7 | import spatutorial.client.logger._ 8 | import spatutorial.client.modules._ 9 | import spatutorial.client.services.SPACircuit 10 | 11 | import scala.scalajs.js 12 | import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel} 13 | import CssSettings._ 14 | import scalacss.ScalaCssReact._ 15 | 16 | @JSExportTopLevel("SPAMain") 17 | object SPAMain extends js.JSApp { 18 | 19 | // Define the locations (pages) used in this application 20 | sealed trait Loc 21 | 22 | case object DashboardLoc extends Loc 23 | 24 | case object TodoLoc extends Loc 25 | 26 | // configure the router 27 | val routerConfig = RouterConfigDsl[Loc].buildConfig { dsl => 28 | import dsl._ 29 | 30 | val todoWrapper = SPACircuit.connect(_.todos) 31 | // wrap/connect components to the circuit 32 | (staticRoute(root, DashboardLoc) ~> renderR(ctl => SPACircuit.wrap(_.motd)(proxy => Dashboard(ctl, proxy))) 33 | | staticRoute("#todo", TodoLoc) ~> renderR(ctl => todoWrapper(Todo(_))) 34 | ).notFound(redirectToPage(DashboardLoc)(Redirect.Replace)) 35 | }.renderWith(layout) 36 | 37 | val todoCountWrapper = SPACircuit.connect(_.todos.map(_.items.count(!_.completed)).toOption) 38 | // base layout for all pages 39 | def layout(c: RouterCtl[Loc], r: Resolution[Loc]) = { 40 | <.div( 41 | // here we use plain Bootstrap class names as these are specific to the top level layout defined here 42 | <.nav(^.className := "navbar navbar-inverse navbar-fixed-top", 43 | <.div(^.className := "container", 44 | <.div(^.className := "navbar-header", <.span(^.className := "navbar-brand", "SPA Tutorial")), 45 | <.div(^.className := "collapse navbar-collapse", 46 | // connect menu to model, because it needs to update when the number of open todos changes 47 | todoCountWrapper(proxy => MainMenu(c, r.page, proxy)) 48 | ) 49 | ) 50 | ), 51 | // currently active module is shown in this container 52 | <.div(^.className := "container", r.render()) 53 | ) 54 | } 55 | 56 | @JSExport 57 | def main(): Unit = { 58 | log.warn("Application starting") 59 | // send log messages also to the server 60 | log.enableServerLogging("/logging") 61 | log.info("This message goes to server as well") 62 | 63 | // create stylesheet 64 | GlobalStyles.addToDocument() 65 | // create the router 66 | val router = Router(BaseUrl.until_#, routerConfig) 67 | // tell React to render the router in the document body 68 | router().renderIntoDOM(dom.document.getElementById("root")) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/Bootstrap.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.components 2 | 3 | import japgolly.scalajs.react._ 4 | import japgolly.scalajs.react.vdom.html_<^._ 5 | 6 | import scala.language.implicitConversions 7 | import scala.scalajs.js 8 | import scalacss.ScalaCssReact._ 9 | import spatutorial.client.CssSettings._ 10 | 11 | /** 12 | * Common Bootstrap components for scalajs-react 13 | */ 14 | object Bootstrap { 15 | 16 | // shorthand for styles 17 | @inline private def bss = GlobalStyles.bootstrapStyles 18 | 19 | @js.native 20 | trait BootstrapJQuery extends JQuery { 21 | def modal(action: String): BootstrapJQuery = js.native 22 | def modal(options: js.Any): BootstrapJQuery = js.native 23 | } 24 | 25 | implicit def jq2bootstrap(jq: JQuery): BootstrapJQuery = jq.asInstanceOf[BootstrapJQuery] 26 | 27 | // Common Bootstrap contextual styles 28 | object CommonStyle extends Enumeration { 29 | val default, primary, success, info, warning, danger = Value 30 | } 31 | 32 | object Button { 33 | 34 | case class Props(onClick: Callback, style: CommonStyle.Value = CommonStyle.default, addStyles: Seq[StyleA] = Seq()) 35 | 36 | val component = ScalaComponent.builder[Props]("Button") 37 | .renderPC((_, p, c) => 38 | <.button(bss.buttonOpt(p.style), p.addStyles.toTagMod, ^.tpe := "button", ^.onClick --> p.onClick, c) 39 | ).build 40 | 41 | def apply(props: Props, children: VdomNode*) = component(props)(children: _*) 42 | def apply() = component 43 | } 44 | 45 | object Panel { 46 | 47 | case class Props(heading: String, style: CommonStyle.Value = CommonStyle.default) 48 | 49 | val component = ScalaComponent.builder[Props]("Panel") 50 | .renderPC((_, p, c) => 51 | <.div(bss.panelOpt(p.style), 52 | <.div(bss.panelHeading, p.heading), 53 | <.div(bss.panelBody, c) 54 | ) 55 | ).build 56 | 57 | def apply(props: Props, children: VdomNode*) = component(props)(children: _*) 58 | def apply() = component 59 | } 60 | 61 | object Modal { 62 | 63 | // header and footer are functions, so that they can get access to the the hide() function for their buttons 64 | case class Props(header: Callback => VdomNode, footer: Callback => VdomNode, closed: Callback, backdrop: Boolean = true, 65 | keyboard: Boolean = true) 66 | 67 | class Backend(t: BackendScope[Props, Unit]) { 68 | def hide = 69 | // instruct Bootstrap to hide the modal 70 | t.getDOMNode.map(jQuery(_).modal("hide")).void 71 | 72 | // jQuery event handler to be fired when the modal has been hidden 73 | def hidden(e: JQueryEventObject): js.Any = { 74 | // inform the owner of the component that the modal was closed/hidden 75 | t.props.flatMap(_.closed).runNow() 76 | } 77 | 78 | def render(p: Props, c: PropsChildren) = { 79 | val modalStyle = bss.modal 80 | <.div(modalStyle.modal, modalStyle.fade, ^.role := "dialog", ^.aria.hidden := true, 81 | <.div(modalStyle.dialog, 82 | <.div(modalStyle.content, 83 | <.div(modalStyle.header, p.header(hide)), 84 | <.div(modalStyle.body, c), 85 | <.div(modalStyle.footer, p.footer(hide)) 86 | ) 87 | ) 88 | ) 89 | } 90 | } 91 | 92 | val component = ScalaComponent.builder[Props]("Modal") 93 | .renderBackendWithChildren[Backend] 94 | .componentDidMount(scope => Callback { 95 | val p = scope.props 96 | // instruct Bootstrap to show the modal 97 | jQuery(scope.getDOMNode).modal(js.Dynamic.literal("backdrop" -> p.backdrop, "keyboard" -> p.keyboard, "show" -> true)) 98 | // register event listener to be notified when the modal is closed 99 | jQuery(scope.getDOMNode).on("hidden.bs.modal", null, null, scope.backend.hidden _) 100 | }) 101 | .build 102 | 103 | def apply(props: Props, children: VdomElement*) = component(props)(children: _*) 104 | def apply() = component 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/BootstrapStyles.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.components 2 | 3 | import japgolly.univeq.UnivEq 4 | import spatutorial.client.components.Bootstrap.CommonStyle 5 | 6 | import spatutorial.client.CssSettings._ 7 | import scalacss.internal.mutable 8 | import spatutorial.client.components.Bootstrap.CommonStyle._ 9 | 10 | class BootstrapStyles(implicit r: mutable.Register) extends StyleSheet.Inline()(r) { 11 | 12 | import dsl._ 13 | 14 | implicit val styleUnivEq: UnivEq[CommonStyle.Value] = new UnivEq[CommonStyle.Value] {} 15 | 16 | val csDomain = Domain.ofValues(default, primary, success, info, warning, danger) 17 | 18 | val contextDomain = Domain.ofValues(success, info, warning, danger) 19 | 20 | def commonStyle[A: UnivEq](domain: Domain[A], base: String) = styleF(domain)(opt => 21 | styleS(addClassNames(base, s"$base-$opt")) 22 | ) 23 | 24 | def styleWrap(classNames: String*) = style(addClassNames(classNames: _*)) 25 | 26 | val buttonOpt = commonStyle(csDomain, "btn") 27 | 28 | val button = buttonOpt(default) 29 | 30 | val panelOpt = commonStyle(csDomain, "panel") 31 | 32 | val panel = panelOpt(default) 33 | 34 | val labelOpt = commonStyle(csDomain, "label") 35 | 36 | val label = labelOpt(default) 37 | 38 | val alert = commonStyle(contextDomain, "alert") 39 | 40 | val panelHeading = styleWrap("panel-heading") 41 | 42 | val panelBody = styleWrap("panel-body") 43 | 44 | // wrap styles in a namespace, assign to val to prevent lazy initialization 45 | object modal { 46 | val modal = styleWrap("modal") 47 | val fade = styleWrap("fade") 48 | val dialog = styleWrap("modal-dialog") 49 | val content = styleWrap("modal-content") 50 | val header = styleWrap("modal-header") 51 | val body = styleWrap("modal-body") 52 | val footer = styleWrap("modal-footer") 53 | } 54 | 55 | val _modal = modal 56 | 57 | object listGroup { 58 | val listGroup = styleWrap("list-group") 59 | val item = styleWrap("list-group-item") 60 | val itemOpt = commonStyle(contextDomain, "list-group-item") 61 | } 62 | 63 | val _listGroup = listGroup 64 | val pullRight = styleWrap("pull-right") 65 | val buttonXS = styleWrap("btn-xs") 66 | val close = styleWrap("close") 67 | 68 | val labelAsBadge = style(addClassName("label-as-badge"), borderRadius(1.em)) 69 | 70 | val navbar = styleWrap("nav", "navbar-nav") 71 | 72 | val formGroup = styleWrap("form-group") 73 | val formControl = styleWrap("form-control") 74 | } 75 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/Chart.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.components 2 | 3 | import japgolly.scalajs.react.vdom.html_<^._ 4 | import japgolly.scalajs.react.{Callback, ScalaComponent} 5 | import org.scalajs.dom.raw.HTMLCanvasElement 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.JSConverters._ 9 | import scala.scalajs.js.annotation.JSGlobal 10 | 11 | @js.native 12 | trait ChartDataset extends js.Object { 13 | def label: String = js.native 14 | def data: js.Array[Double] = js.native 15 | def fillColor: String = js.native 16 | def strokeColor: String = js.native 17 | } 18 | 19 | object ChartDataset { 20 | def apply(data: Seq[Double], 21 | label: String, backgroundColor: String = "#8080FF", borderColor: String = "#404080"): ChartDataset = { 22 | js.Dynamic.literal( 23 | label = label, 24 | data = data.toJSArray, 25 | backgroundColor = backgroundColor, 26 | borderColor = borderColor 27 | ).asInstanceOf[ChartDataset] 28 | } 29 | } 30 | 31 | @js.native 32 | trait ChartData extends js.Object { 33 | def labels: js.Array[String] = js.native 34 | def datasets: js.Array[ChartDataset] = js.native 35 | } 36 | 37 | object ChartData { 38 | def apply(labels: Seq[String], datasets: Seq[ChartDataset]): ChartData = { 39 | js.Dynamic.literal( 40 | labels = labels.toJSArray, 41 | datasets = datasets.toJSArray 42 | ).asInstanceOf[ChartData] 43 | } 44 | } 45 | 46 | @js.native 47 | trait ChartOptions extends js.Object { 48 | def responsive: Boolean = js.native 49 | } 50 | 51 | object ChartOptions { 52 | def apply(responsive: Boolean = true): ChartOptions = { 53 | js.Dynamic.literal( 54 | responsive = responsive 55 | ).asInstanceOf[ChartOptions] 56 | } 57 | } 58 | 59 | @js.native 60 | trait ChartConfiguration extends js.Object { 61 | def `type`: String = js.native 62 | def data: ChartData = js.native 63 | def options: ChartOptions = js.native 64 | } 65 | 66 | object ChartConfiguration { 67 | def apply(`type`: String, data: ChartData, options: ChartOptions = ChartOptions(false)): ChartConfiguration = { 68 | js.Dynamic.literal( 69 | `type` = `type`, 70 | data = data, 71 | options = options 72 | ).asInstanceOf[ChartConfiguration] 73 | } 74 | } 75 | 76 | // define a class to access the Chart.js component 77 | @js.native 78 | @JSGlobal("Chart") 79 | class JSChart(ctx: js.Dynamic, config: ChartConfiguration) extends js.Object 80 | 81 | object Chart { 82 | 83 | // available chart styles 84 | sealed trait ChartStyle 85 | 86 | case object LineChart extends ChartStyle 87 | 88 | case object BarChart extends ChartStyle 89 | 90 | case class ChartProps(name: String, style: ChartStyle, data: ChartData, width: Int = 500, height: Int = 300) 91 | 92 | val Chart = ScalaComponent.builder[ChartProps]("Chart") 93 | .render_P(p => 94 | <.canvas(VdomAttr("width") := p.width, VdomAttr("height") := p.height) 95 | ) 96 | .componentDidMount(scope => Callback { 97 | // access context of the canvas 98 | val ctx = scope.getDOMNode.asInstanceOf[HTMLCanvasElement].getContext("2d") 99 | // create the actual chart using the 3rd party component 100 | scope.props.style match { 101 | case LineChart => new JSChart(ctx, ChartConfiguration("line", scope.props.data)) 102 | case BarChart => new JSChart(ctx, ChartConfiguration("bar", scope.props.data)) 103 | case _ => throw new IllegalArgumentException 104 | } 105 | }).build 106 | 107 | def apply(props: ChartProps) = Chart(props) 108 | } 109 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/GlobalStyles.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.components 2 | 3 | import spatutorial.client.CssSettings._ 4 | 5 | object GlobalStyles extends StyleSheet.Inline { 6 | import dsl._ 7 | 8 | style(unsafeRoot("body")( 9 | paddingTop(70.px)) 10 | ) 11 | 12 | val bootstrapStyles = new BootstrapStyles 13 | } 14 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/JQuery.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.components 2 | 3 | import org.scalajs.dom._ 4 | 5 | import scala.scalajs.js 6 | import scala.scalajs.js.annotation.JSGlobal 7 | 8 | /** 9 | * Minimal facade for JQuery. Use https://github.com/scala-js/scala-js-jquery or 10 | * https://github.com/jducoeur/jquery-facade for more complete one. 11 | */ 12 | @js.native 13 | trait JQueryEventObject extends Event { 14 | var data: js.Any = js.native 15 | } 16 | 17 | @js.native 18 | @JSGlobal("jQuery") 19 | object JQueryStatic extends js.Object { 20 | def apply(element: Element): JQuery = js.native 21 | } 22 | 23 | @js.native 24 | trait JQuery extends js.Object { 25 | def on(events: String, selector: js.Any, data: js.Any, handler: js.Function1[JQueryEventObject, js.Any]): JQuery = js.native 26 | def off(events: String): JQuery = js.native 27 | } -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/Motd.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.components 2 | 3 | import diode.react.ReactPot._ 4 | import diode.react._ 5 | import diode.data.Pot 6 | import japgolly.scalajs.react._ 7 | import japgolly.scalajs.react.vdom.html_<^._ 8 | import spatutorial.client.components.Bootstrap._ 9 | import spatutorial.client.services.UpdateMotd 10 | 11 | /** 12 | * This is a simple component demonstrating how to display async data coming from the server 13 | */ 14 | object Motd { 15 | 16 | // create the React component for holding the Message of the Day 17 | val Motd = ScalaComponent.builder[ModelProxy[Pot[String]]]("Motd") 18 | .render_P { proxy => 19 | Panel(Panel.Props("Message of the day"), 20 | // render messages depending on the state of the Pot 21 | proxy().renderPending(_ > 500, _ => <.p("Loading...")), 22 | proxy().renderFailed(ex => <.p("Failed to load")), 23 | proxy().render(m => <.p(m)), 24 | Button(Button.Props(proxy.dispatchCB(UpdateMotd()), CommonStyle.danger), Icon.refresh, " Update") 25 | ) 26 | } 27 | .componentDidMount(scope => 28 | // update only if Motd is empty 29 | Callback.when(scope.props.value.isEmpty)(scope.props.dispatchCB(UpdateMotd())) 30 | ) 31 | .build 32 | 33 | def apply(proxy: ModelProxy[Pot[String]]) = Motd(proxy) 34 | } 35 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/TodoList.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.components 2 | 3 | import japgolly.scalajs.react._ 4 | import japgolly.scalajs.react.vdom.html_<^._ 5 | import spatutorial.client.components.Bootstrap.{CommonStyle, Button} 6 | import spatutorial.shared._ 7 | import scalacss.ScalaCssReact._ 8 | 9 | object TodoList { 10 | // shorthand for styles 11 | @inline private def bss = GlobalStyles.bootstrapStyles 12 | 13 | case class TodoListProps( 14 | items: Seq[TodoItem], 15 | stateChange: TodoItem => Callback, 16 | editItem: TodoItem => Callback, 17 | deleteItem: TodoItem => Callback 18 | ) 19 | 20 | private val TodoList = ScalaComponent.builder[TodoListProps]("TodoList") 21 | .render_P(p => { 22 | val style = bss.listGroup 23 | def renderItem(item: TodoItem) = { 24 | // convert priority into Bootstrap style 25 | val itemStyle = item.priority match { 26 | case TodoLow => style.itemOpt(CommonStyle.info) 27 | case TodoNormal => style.item 28 | case TodoHigh => style.itemOpt(CommonStyle.danger) 29 | } 30 | <.li(itemStyle, 31 | <.input.checkbox(^.checked := item.completed, ^.onChange --> p.stateChange(item.copy(completed = !item.completed))), 32 | <.span(" "), 33 | if (item.completed) <.s(item.content) else <.span(item.content), 34 | Button(Button.Props(p.editItem(item), addStyles = Seq(bss.pullRight, bss.buttonXS)), "Edit"), 35 | Button(Button.Props(p.deleteItem(item), addStyles = Seq(bss.pullRight, bss.buttonXS)), "Delete") 36 | ) 37 | } 38 | <.ul(style.listGroup)(p.items toTagMod renderItem) 39 | }) 40 | .build 41 | 42 | def apply(items: Seq[TodoItem], stateChange: TodoItem => Callback, editItem: TodoItem => Callback, deleteItem: TodoItem => Callback) = 43 | TodoList(TodoListProps(items, stateChange, editItem, deleteItem)) 44 | } 45 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/components/package.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client 2 | 3 | package object components { 4 | // expose jQuery under a more familiar name 5 | val jQuery = JQueryStatic 6 | } 7 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/logger/Log4JavaScript.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.logger 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSGlobal 5 | 6 | /** 7 | * Facade for functions in log4javascript that we need 8 | */ 9 | @js.native 10 | private[logger] trait Log4JavaScript extends js.Object { 11 | def getLogger(name:js.UndefOr[String]):JSLogger = js.native 12 | def setEnabled(enabled:Boolean):Unit = js.native 13 | def isEnabled:Boolean = js.native 14 | } 15 | 16 | @js.native 17 | private[logger] trait Level extends js.Object { 18 | val ALL:Level = js.native 19 | val TRACE:Level = js.native 20 | val DEBUG:Level = js.native 21 | val INFO:Level = js.native 22 | val WARN:Level = js.native 23 | val ERROR:Level = js.native 24 | val FATAL:Level = js.native 25 | } 26 | 27 | @js.native 28 | private[logger] trait JSLogger extends js.Object { 29 | def addAppender(appender:Appender):Unit = js.native 30 | def removeAppender(appender:Appender):Unit = js.native 31 | def removeAllAppenders(appender:Appender):Unit = js.native 32 | def setLevel(level:Level):Unit = js.native 33 | def getLevel:Level = js.native 34 | def trace(msg:String, error:js.UndefOr[js.Error]):Unit = js.native 35 | def debug(msg:String, error:js.UndefOr[js.Error]):Unit = js.native 36 | def info(msg:String, error:js.UndefOr[js.Error]):Unit = js.native 37 | def warn(msg:String, error:js.UndefOr[js.Error]):Unit = js.native 38 | def error(msg:String, error:js.UndefOr[js.Error]):Unit = js.native 39 | def fatal(msg:String, error:js.UndefOr[js.Error]):Unit = js.native 40 | def trace(msg:String):Unit = js.native 41 | def debug(msg:String):Unit = js.native 42 | def info(msg:String):Unit = js.native 43 | def warn(msg:String):Unit = js.native 44 | def error(msg:String):Unit = js.native 45 | def fatal(msg:String):Unit = js.native 46 | } 47 | 48 | @js.native 49 | private[logger] trait Layout extends js.Object 50 | 51 | @js.native 52 | @JSGlobal("log4javascript.JsonLayout") 53 | private[logger] class JsonLayout extends Layout 54 | 55 | @js.native 56 | private[logger] trait Appender extends js.Object { 57 | def setLayout(layout:Layout):Unit = js.native 58 | def setThreshold(level:Level):Unit = js.native 59 | } 60 | 61 | @js.native 62 | @JSGlobal("log4javascript.BrowserConsoleAppender") 63 | private[logger] class BrowserConsoleAppender extends Appender 64 | 65 | @js.native 66 | @JSGlobal("log4javascript.PopUpAppender") 67 | private[logger] class PopUpAppender extends Appender 68 | 69 | @js.native 70 | @JSGlobal("log4javascript.AjaxAppender") 71 | private[logger] class AjaxAppender(url:String) extends Appender { 72 | def addHeader(header:String, value:String):Unit = js.native 73 | } 74 | 75 | @js.native 76 | @js.annotation.JSGlobalScope 77 | private[logger] object Log4JavaScript extends js.Object { 78 | val log4javascript:Log4JavaScript = js.native 79 | } 80 | 81 | class L4JSLogger(jsLogger:JSLogger) extends Logger { 82 | 83 | private var ajaxAppender:AjaxAppender = null 84 | 85 | private def undefOrError(e:Exception):js.UndefOr[js.Error] = { 86 | if(e == null) 87 | js.undefined 88 | else 89 | e.asInstanceOf[js.Error] 90 | } 91 | 92 | override def trace(msg: String, e: Exception): Unit = jsLogger.trace(msg, undefOrError(e)) 93 | override def trace(msg: String): Unit = jsLogger.trace(msg) 94 | override def debug(msg: String, e: Exception): Unit = jsLogger.debug(msg, undefOrError(e)) 95 | override def debug(msg: String): Unit = jsLogger.debug(msg) 96 | override def info(msg: String, e: Exception): Unit = jsLogger.info(msg, undefOrError(e)) 97 | override def info(msg: String): Unit = jsLogger.info(msg) 98 | override def warn(msg: String, e: Exception): Unit = jsLogger.warn(msg, undefOrError(e)) 99 | override def warn(msg: String): Unit = jsLogger.warn(msg) 100 | override def error(msg: String, e: Exception): Unit = jsLogger.error(msg, undefOrError(e)) 101 | override def error(msg: String): Unit = jsLogger.error(msg) 102 | override def fatal(msg: String, e: Exception): Unit = jsLogger.fatal(msg, undefOrError(e)) 103 | override def fatal(msg: String): Unit = jsLogger.fatal(msg) 104 | 105 | override def enableServerLogging(url: String): Unit = { 106 | if(ajaxAppender == null) { 107 | ajaxAppender = new AjaxAppender(url) 108 | ajaxAppender.addHeader("Content-Type", "application/json") 109 | ajaxAppender.setLayout(new JsonLayout) 110 | jsLogger.addAppender(ajaxAppender) 111 | 112 | } 113 | } 114 | 115 | override def disableServerLogging():Unit = { 116 | if(ajaxAppender != null) { 117 | jsLogger.removeAppender(ajaxAppender) 118 | ajaxAppender = null 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/logger/LoggerFactory.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.logger 2 | 3 | import scala.annotation.elidable 4 | import scala.annotation.elidable._ 5 | 6 | trait Logger { 7 | /* 8 | * Use @elidable annotation to completely exclude functions from the compiler generated byte-code based on 9 | * the specified level. In a production build most logging functions will simply disappear with no runtime 10 | * performance penalty. 11 | * 12 | * Specify level as a compiler parameter 13 | * > scalac -Xelide-below INFO 14 | */ 15 | @elidable(FINEST) def trace(msg: String, e: Exception): Unit 16 | @elidable(FINEST) def trace(msg: String): Unit 17 | @elidable(FINE) def debug(msg: String, e: Exception): Unit 18 | @elidable(FINE) def debug(msg: String): Unit 19 | @elidable(INFO) def info(msg: String, e: Exception): Unit 20 | @elidable(INFO) def info(msg: String): Unit 21 | @elidable(WARNING) def warn(msg: String, e: Exception): Unit 22 | @elidable(WARNING) def warn(msg: String): Unit 23 | @elidable(SEVERE) def error(msg: String, e: Exception): Unit 24 | @elidable(SEVERE) def error(msg: String): Unit 25 | @elidable(SEVERE) def fatal(msg: String, e: Exception): Unit 26 | @elidable(SEVERE) def fatal(msg: String): Unit 27 | 28 | def enableServerLogging(url: String): Unit 29 | def disableServerLogging(): Unit 30 | } 31 | 32 | object LoggerFactory { 33 | private[logger] def createLogger(name: String) = {} 34 | 35 | lazy val consoleAppender = new BrowserConsoleAppender 36 | lazy val popupAppender = new PopUpAppender 37 | 38 | /** 39 | * Create a logger that outputs to browser console 40 | */ 41 | def getLogger(name: String): Logger = { 42 | val nativeLogger = Log4JavaScript.log4javascript.getLogger(name) 43 | nativeLogger.addAppender(consoleAppender) 44 | new L4JSLogger(nativeLogger) 45 | } 46 | 47 | /** 48 | * Create a logger that outputs to a separate popup window 49 | */ 50 | def getPopUpLogger(name: String): Logger = { 51 | val nativeLogger = Log4JavaScript.log4javascript.getLogger(name) 52 | nativeLogger.addAppender(popupAppender) 53 | new L4JSLogger(nativeLogger) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/logger/package.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client 2 | 3 | package object logger { 4 | private val defaultLogger = LoggerFactory.getLogger("Log") 5 | 6 | def log = defaultLogger 7 | } 8 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/modules/Dashboard.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.modules 2 | 3 | import diode.data.Pot 4 | import diode.react._ 5 | import japgolly.scalajs.react._ 6 | import japgolly.scalajs.react.extra.router.RouterCtl 7 | import japgolly.scalajs.react.vdom.html_<^._ 8 | import spatutorial.client.SPAMain.{Loc, TodoLoc} 9 | import spatutorial.client.components._ 10 | 11 | import scala.util.Random 12 | import scala.language.existentials 13 | 14 | object Dashboard { 15 | 16 | case class Props(router: RouterCtl[Loc], proxy: ModelProxy[Pot[String]]) 17 | 18 | case class State(motdWrapper: ReactConnectProxy[Pot[String]]) 19 | 20 | // create dummy data for the chart 21 | val cp = Chart.ChartProps( 22 | "Test chart", 23 | Chart.BarChart, 24 | ChartData( 25 | Random.alphanumeric.map(_.toUpper.toString).distinct.take(10), 26 | Seq(ChartDataset(Iterator.continually(Random.nextDouble() * 10).take(10).toSeq, "Data1")) 27 | ) 28 | ) 29 | 30 | // create the React component for Dashboard 31 | private val component = ScalaComponent.builder[Props]("Dashboard") 32 | // create and store the connect proxy in state for later use 33 | .initialStateFromProps(props => State(props.proxy.connect(m => m))) 34 | .renderPS { (_, props, state) => 35 | <.div( 36 | // header, MessageOfTheDay and chart components 37 | <.h2("Dashboard"), 38 | state.motdWrapper(Motd(_)), 39 | Chart(cp), 40 | // create a link to the To Do view 41 | <.div(props.router.link(TodoLoc)("Check your todos!")) 42 | ) 43 | } 44 | .build 45 | 46 | def apply(router: RouterCtl[Loc], proxy: ModelProxy[Pot[String]]) = component(Props(router, proxy)) 47 | } 48 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/modules/MainMenu.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.modules 2 | 3 | import diode.react.ModelProxy 4 | import japgolly.scalajs.react._ 5 | import japgolly.scalajs.react.extra.router.RouterCtl 6 | import japgolly.scalajs.react.vdom.html_<^._ 7 | import spatutorial.client.SPAMain.{DashboardLoc, Loc, TodoLoc} 8 | import spatutorial.client.components.Bootstrap.CommonStyle 9 | import spatutorial.client.components.Icon._ 10 | import spatutorial.client.components._ 11 | import spatutorial.client.services._ 12 | 13 | import scalacss.ScalaCssReact._ 14 | 15 | object MainMenu { 16 | // shorthand for styles 17 | @inline private def bss = GlobalStyles.bootstrapStyles 18 | 19 | case class Props(router: RouterCtl[Loc], currentLoc: Loc, proxy: ModelProxy[Option[Int]]) 20 | 21 | private case class MenuItem(idx: Int, label: (Props) => VdomNode, icon: Icon, location: Loc) 22 | 23 | // build the Todo menu item, showing the number of open todos 24 | private def buildTodoMenu(props: Props): VdomElement = { 25 | val todoCount = props.proxy().getOrElse(0) 26 | <.span( 27 | <.span("Todo "), 28 | <.span(bss.labelOpt(CommonStyle.danger), bss.labelAsBadge, todoCount).when(todoCount > 0) 29 | ) 30 | } 31 | 32 | private val menuItems = Seq( 33 | MenuItem(1, _ => "Dashboard", Icon.dashboard, DashboardLoc), 34 | MenuItem(2, buildTodoMenu, Icon.check, TodoLoc) 35 | ) 36 | 37 | private class Backend($: BackendScope[Props, Unit]) { 38 | def mounted(props: Props) = 39 | // dispatch a message to refresh the todos 40 | Callback.when(props.proxy.value.isEmpty)(props.proxy.dispatchCB(RefreshTodos)) 41 | 42 | def render(props: Props) = { 43 | <.ul(bss.navbar)( 44 | // build a list of menu items 45 | menuItems.toVdomArray(item => 46 | <.li(^.key := item.idx, (^.className := "active").when(props.currentLoc == item.location), 47 | props.router.link(item.location)(item.icon, " ", item.label(props)) 48 | )) 49 | ) 50 | } 51 | } 52 | 53 | private val component = ScalaComponent.builder[Props]("MainMenu") 54 | .renderBackend[Backend] 55 | .componentDidMount(scope => scope.backend.mounted(scope.props)) 56 | .build 57 | 58 | def apply(ctl: RouterCtl[Loc], currentLoc: Loc, proxy: ModelProxy[Option[Int]]): VdomElement = 59 | component(Props(ctl, currentLoc, proxy)) 60 | } 61 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/modules/TODO.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.modules 2 | 3 | import diode.react.ReactPot._ 4 | import diode.react._ 5 | import diode.data.Pot 6 | import japgolly.scalajs.react._ 7 | import japgolly.scalajs.react.vdom.html_<^._ 8 | import spatutorial.client.components.Bootstrap._ 9 | import spatutorial.client.components._ 10 | import spatutorial.client.logger._ 11 | import spatutorial.client.services._ 12 | import spatutorial.shared._ 13 | 14 | import scalacss.ScalaCssReact._ 15 | 16 | object Todo { 17 | 18 | case class Props(proxy: ModelProxy[Pot[Todos]]) 19 | 20 | case class State(selectedItem: Option[TodoItem] = None, showTodoForm: Boolean = false) 21 | 22 | class Backend($: BackendScope[Props, State]) { 23 | def mounted(props: Props) = 24 | // dispatch a message to refresh the todos, which will cause TodoStore to fetch todos from the server 25 | Callback.when(props.proxy().isEmpty)(props.proxy.dispatchCB(RefreshTodos)) 26 | 27 | def editTodo(item: Option[TodoItem]) = 28 | // activate the edit dialog 29 | $.modState(s => s.copy(selectedItem = item, showTodoForm = true)) 30 | 31 | def todoEdited(item: TodoItem, cancelled: Boolean) = { 32 | val cb = if (cancelled) { 33 | // nothing to do here 34 | Callback.log("Todo editing cancelled") 35 | } else { 36 | Callback.log(s"Todo edited: $item") >> 37 | $.props >>= (_.proxy.dispatchCB(UpdateTodo(item))) 38 | } 39 | // hide the edit dialog, chain callbacks 40 | cb >> $.modState(s => s.copy(showTodoForm = false)) 41 | } 42 | 43 | def render(p: Props, s: State) = 44 | Panel(Panel.Props("What needs to be done"), <.div( 45 | p.proxy().renderFailed(ex => "Error loading"), 46 | p.proxy().renderPending(_ > 500, _ => "Loading..."), 47 | p.proxy().render(todos => TodoList(todos.items, item => p.proxy.dispatchCB(UpdateTodo(item)), 48 | item => editTodo(Some(item)), item => p.proxy.dispatchCB(DeleteTodo(item)))), 49 | Button(Button.Props(editTodo(None)), Icon.plusSquare, " New")), 50 | // if the dialog is open, add it to the panel 51 | if (s.showTodoForm) TodoForm(TodoForm.Props(s.selectedItem, todoEdited)) 52 | else // otherwise add an empty placeholder 53 | VdomArray.empty()) 54 | } 55 | 56 | // create the React component for To Do management 57 | val component = ScalaComponent.builder[Props]("TODO") 58 | .initialState(State()) // initial state from TodoStore 59 | .renderBackend[Backend] 60 | .componentDidMount(scope => scope.backend.mounted(scope.props)) 61 | .build 62 | 63 | /** Returns a function compatible with router location system while using our own props */ 64 | def apply(proxy: ModelProxy[Pot[Todos]]) = component(Props(proxy)) 65 | } 66 | 67 | object TodoForm { 68 | // shorthand for styles 69 | @inline private def bss = GlobalStyles.bootstrapStyles 70 | 71 | case class Props(item: Option[TodoItem], submitHandler: (TodoItem, Boolean) => Callback) 72 | 73 | case class State(item: TodoItem, cancelled: Boolean = true) 74 | 75 | class Backend(t: BackendScope[Props, State]) { 76 | def submitForm(): Callback = { 77 | // mark it as NOT cancelled (which is the default) 78 | t.modState(s => s.copy(cancelled = false)) 79 | } 80 | 81 | def formClosed(state: State, props: Props): Callback = 82 | // call parent handler with the new item and whether form was OK or cancelled 83 | props.submitHandler(state.item, state.cancelled) 84 | 85 | def updateDescription(e: ReactEventFromInput) = { 86 | val text = e.target.value 87 | // update TodoItem content 88 | t.modState(s => s.copy(item = s.item.copy(content = text))) 89 | } 90 | 91 | def updatePriority(e: ReactEventFromInput) = { 92 | // update TodoItem priority 93 | val newPri = e.currentTarget.value match { 94 | case p if p == TodoHigh.toString => TodoHigh 95 | case p if p == TodoNormal.toString => TodoNormal 96 | case p if p == TodoLow.toString => TodoLow 97 | } 98 | t.modState(s => s.copy(item = s.item.copy(priority = newPri))) 99 | } 100 | 101 | def render(p: Props, s: State) = { 102 | log.debug(s"User is ${if (s.item.id == "") "adding" else "editing"} a todo or two") 103 | val headerText = if (s.item.id == "") "Add new todo" else "Edit todo" 104 | Modal(Modal.Props( 105 | // header contains a cancel button (X) 106 | header = hide => <.span(<.button(^.tpe := "button", bss.close, ^.onClick --> hide, Icon.close), <.h4(headerText)), 107 | // footer has the OK button that submits the form before hiding it 108 | footer = hide => <.span(Button(Button.Props(submitForm() >> hide), "OK")), 109 | // this is called after the modal has been hidden (animation is completed) 110 | closed = formClosed(s, p)), 111 | <.div(bss.formGroup, 112 | <.label(^.`for` := "description", "Description"), 113 | <.input.text(bss.formControl, ^.id := "description", ^.value := s.item.content, 114 | ^.placeholder := "write description", ^.onChange ==> updateDescription)), 115 | <.div(bss.formGroup, 116 | <.label(^.`for` := "priority", "Priority"), 117 | // using defaultValue = "Normal" instead of option/selected due to React 118 | <.select(bss.formControl, ^.id := "priority", ^.value := s.item.priority.toString, ^.onChange ==> updatePriority, 119 | <.option(^.value := TodoHigh.toString, "High"), 120 | <.option(^.value := TodoNormal.toString, "Normal"), 121 | <.option(^.value := TodoLow.toString, "Low") 122 | ) 123 | ) 124 | ) 125 | } 126 | } 127 | 128 | val component = ScalaComponent.builder[Props]("TodoForm") 129 | .initialStateFromProps(p => State(p.item.getOrElse(TodoItem("", 0, "", TodoNormal, completed = false)))) 130 | .renderBackend[Backend] 131 | .build 132 | 133 | def apply(props: Props) = component(props) 134 | } -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/package.scala: -------------------------------------------------------------------------------- 1 | package spatutorial 2 | 3 | package object client { 4 | 5 | val CssSettings = scalacss.devOrProdDefaults 6 | 7 | } 8 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/services/AjaxClient.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.services 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import boopickle.Default._ 6 | import org.scalajs.dom 7 | 8 | import scala.concurrent.Future 9 | import scala.concurrent.ExecutionContext.Implicits.global 10 | import scala.scalajs.js.typedarray._ 11 | 12 | object AjaxClient extends autowire.Client[ByteBuffer, Pickler, Pickler] { 13 | override def doCall(req: Request): Future[ByteBuffer] = { 14 | dom.ext.Ajax.post( 15 | url = "/api/" + req.path.mkString("/"), 16 | data = Pickle.intoBytes(req.args), 17 | responseType = "arraybuffer", 18 | headers = Map("Content-Type" -> "application/octet-stream") 19 | ).map(r => TypedArrayBuffer.wrap(r.response.asInstanceOf[ArrayBuffer])) 20 | } 21 | 22 | override def read[Result: Pickler](p: ByteBuffer) = Unpickle[Result].fromBytes(p) 23 | override def write[Result: Pickler](r: Result) = Pickle.intoBytes(r) 24 | } 25 | -------------------------------------------------------------------------------- /client/src/main/scala/spatutorial/client/services/SPACircuit.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.services 2 | 3 | import autowire._ 4 | import diode._ 5 | import diode.data._ 6 | import diode.util._ 7 | import diode.react.ReactConnector 8 | import spatutorial.shared.{TodoItem, Api} 9 | import boopickle.Default._ 10 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 11 | 12 | // Actions 13 | case object RefreshTodos extends Action 14 | 15 | case class UpdateAllTodos(todos: Seq[TodoItem]) extends Action 16 | 17 | case class UpdateTodo(item: TodoItem) extends Action 18 | 19 | case class DeleteTodo(item: TodoItem) extends Action 20 | 21 | case class UpdateMotd(potResult: Pot[String] = Empty) extends PotAction[String, UpdateMotd] { 22 | override def next(value: Pot[String]) = UpdateMotd(value) 23 | } 24 | 25 | // The base model of our application 26 | case class RootModel(todos: Pot[Todos], motd: Pot[String]) 27 | 28 | case class Todos(items: Seq[TodoItem]) { 29 | def updated(newItem: TodoItem) = { 30 | items.indexWhere(_.id == newItem.id) match { 31 | case -1 => 32 | // add new 33 | Todos(items :+ newItem) 34 | case idx => 35 | // replace old 36 | Todos(items.updated(idx, newItem)) 37 | } 38 | } 39 | def remove(item: TodoItem) = Todos(items.filterNot(_ == item)) 40 | } 41 | 42 | /** 43 | * Handles actions related to todos 44 | * 45 | * @param modelRW Reader/Writer to access the model 46 | */ 47 | class TodoHandler[M](modelRW: ModelRW[M, Pot[Todos]]) extends ActionHandler(modelRW) { 48 | override def handle = { 49 | case RefreshTodos => 50 | effectOnly(Effect(AjaxClient[Api].getAllTodos().call().map(UpdateAllTodos))) 51 | case UpdateAllTodos(todos) => 52 | // got new todos, update model 53 | updated(Ready(Todos(todos))) 54 | case UpdateTodo(item) => 55 | // make a local update and inform server 56 | updated(value.map(_.updated(item)), Effect(AjaxClient[Api].updateTodo(item).call().map(UpdateAllTodos))) 57 | case DeleteTodo(item) => 58 | // make a local update and inform server 59 | updated(value.map(_.remove(item)), Effect(AjaxClient[Api].deleteTodo(item.id).call().map(UpdateAllTodos))) 60 | } 61 | } 62 | 63 | /** 64 | * Handles actions related to the Motd 65 | * 66 | * @param modelRW Reader/Writer to access the model 67 | */ 68 | class MotdHandler[M](modelRW: ModelRW[M, Pot[String]]) extends ActionHandler(modelRW) { 69 | implicit val runner = new RunAfterJS 70 | 71 | override def handle = { 72 | case action: UpdateMotd => 73 | val updateF = action.effect(AjaxClient[Api].welcomeMsg("User X").call())(identity _) 74 | action.handleWith(this, updateF)(PotAction.handler()) 75 | } 76 | } 77 | 78 | // Application circuit 79 | object SPACircuit extends Circuit[RootModel] with ReactConnector[RootModel] { 80 | // initial application model 81 | override protected def initialModel = RootModel(Empty, Empty) 82 | // combine all handlers into one 83 | override protected val actionHandler = composeHandlers( 84 | new TodoHandler(zoomRW(_.todos)((m, v) => m.copy(todos = v))), 85 | new MotdHandler(zoomRW(_.motd)((m, v) => m.copy(motd = v))) 86 | ) 87 | } -------------------------------------------------------------------------------- /client/src/test/scala/spatutorial/client/services/SPACircuitTests.scala: -------------------------------------------------------------------------------- 1 | package spatutorial.client.services 2 | 3 | import diode.ActionResult._ 4 | import diode.RootModelRW 5 | import diode.data._ 6 | import spatutorial.shared._ 7 | import utest._ 8 | 9 | object SPACircuitTests extends TestSuite { 10 | def tests = TestSuite { 11 | 'TodoHandler - { 12 | val model = Ready(Todos(Seq( 13 | TodoItem("1", 0, "Test1", TodoLow, completed = false), 14 | TodoItem("2", 0, "Test2", TodoLow, completed = false), 15 | TodoItem("3", 0, "Test3", TodoHigh, completed = true) 16 | ))) 17 | 18 | val newTodos = Seq( 19 | TodoItem("3", 0, "Test3", TodoHigh, completed = true) 20 | ) 21 | 22 | def build = new TodoHandler(new RootModelRW(model)) 23 | 24 | 'RefreshTodos - { 25 | val h = build 26 | val result = h.handle(RefreshTodos) 27 | result match { 28 | case EffectOnly(effects) => 29 | assert(effects.size == 1) 30 | case _ => 31 | assert(false) 32 | } 33 | } 34 | 35 | 'UpdateAllTodos - { 36 | val h = build 37 | val result = h.handle(UpdateAllTodos(newTodos)) 38 | assert(result == ModelUpdate(Ready(Todos(newTodos)))) 39 | } 40 | 41 | 'UpdateTodoAdd - { 42 | val h = build 43 | val result = h.handle(UpdateTodo(TodoItem("4", 0, "Test4", TodoNormal, completed = false))) 44 | result match { 45 | case ModelUpdateEffect(newValue, effects) => 46 | assert(newValue.get.items.size == 4) 47 | assert(newValue.get.items(3).id == "4") 48 | assert(effects.size == 1) 49 | case _ => 50 | assert(false) 51 | } 52 | } 53 | 54 | 'UpdateTodo - { 55 | val h = build 56 | val result = h.handle(UpdateTodo(TodoItem("1", 0, "Test111", TodoNormal, completed = false))) 57 | result match { 58 | case ModelUpdateEffect(newValue, effects) => 59 | assert(newValue.get.items.size == 3) 60 | assert(newValue.get.items.head.content == "Test111") 61 | assert(effects.size == 1) 62 | case _ => 63 | assert(false) 64 | } 65 | } 66 | 67 | 'DeleteTodo - { 68 | val h = build 69 | val result = h.handle(DeleteTodo(model.get.items.head)) 70 | result match { 71 | case ModelUpdateEffect(newValue, effects) => 72 | assert(newValue.get.items.size == 2) 73 | assert(newValue.get.items.head.content == "Test2") 74 | assert(effects.size == 1) 75 | case _ => 76 | assert(false) 77 | } 78 | } 79 | } 80 | 81 | 'MotdHandler - { 82 | val model: Pot[String] = Ready("Message of the Day!") 83 | def build = new MotdHandler(new RootModelRW(model)) 84 | 85 | 'UpdateMotd - { 86 | val h = build 87 | var result = h.handle(UpdateMotd()) 88 | result match { 89 | case ModelUpdateEffect(newValue, effects) => 90 | assert(newValue.isPending) 91 | assert(effects.size == 1) 92 | case _ => 93 | assert(false) 94 | } 95 | result = h.handle(UpdateMotd(Ready("New message"))) 96 | result match { 97 | case ModelUpdate(newValue) => 98 | assert(newValue.isReady) 99 | assert(newValue.get == "New message") 100 | case _ => 101 | assert(false) 102 | } 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /doc/LANGS.md: -------------------------------------------------------------------------------- 1 | * [English](en/) 2 | * [한국어](kr/) 3 | * [日本語 (にほんご)](jp/) 4 | -------------------------------------------------------------------------------- /doc/en/README.md: -------------------------------------------------------------------------------- 1 | # Scala.js SPA-tutorial 2 | 3 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/ochrons/scalajs-spa-tutorial) 4 | 5 | Tutorial for creating a simple (but potentially complex!) Single Page Application with [Scala.js](http://www.scala-js.org/) and 6 | [Play](https://www.playframework.com/). 7 | 8 | ## Purpose 9 | 10 | This project demonstrates typical design patterns and practices for developing SPAs with Scala.js with special focus on building a complete application. 11 | It started as a way to learn more about Scala.js and related libraries, but then I decided to make it more tutorial-like for the greater good :) 12 | 13 | The code covers typical aspects of building a SPA using Scala.js but it doesn't try to be an all-encompassing example for all the things possible with Scala.js. 14 | Before going through this tutorial, it would be helpful if you already know the basics of Scala.js and have read through the official 15 | [Scala.js tutorial](http://www.scala-js.org/doc/tutorial.html) and the great e-book [Hands-on Scala.js](http://lihaoyi.github.io/hands-on-scala-js/#Hands-onScala.js) 16 | by [Li Haoyi (lihaoyi)](https://github.com/lihaoyi). 17 | 18 | # Code 19 | 20 | Tutorial code is hosted in a [Github repo](https://github.com/ochrons/scalajs-spa-tutorial). 21 | -------------------------------------------------------------------------------- /doc/en/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Getting started](getting-started.md) 4 | - [Application structure](application-structure.md) 5 | - [The Client](the-client.md) 6 | - [Routing](routing.md) 7 | - [Main menu](main-menu.md) 8 | - [Dashboard](dashboard.md) 9 | - [CSS in Scala](css-in-scala.md) 10 | - [Integrating JavaScript components](integrating-javascript-components.md) 11 | - [Todo module and data flow](todo-module-and-data-flow.md) 12 | - [Autowire and BooPickle](autowire-and-boopickle.md) 13 | - [Server side](server-side.md) 14 | - [Testing](testing.md) 15 | - [Debugging](debugging.md) 16 | - [Logging](logging.md) 17 | - [SBT build definition](sbt-build-definition.md) 18 | - [Using resources from WebJars](using-resources-from-webjars.md) 19 | - [Production build](production-build.md) 20 | - [FAQ](faq.md) 21 | - [What next?](what-next.md) 22 | -------------------------------------------------------------------------------- /doc/en/application-structure.md: -------------------------------------------------------------------------------- 1 | # Application structure 2 | 3 | The application is divided into three folders: `client`, `server` and `shared`. As the names imply, `client` contains the client code for the SPA, `server` is the server and 4 | `shared` contains code and resources used by both. If you take a quick look at [`build.sbt`](https://github.com/ochrons/scalajs-spa-tutorial/tree/master/build.sbt) 5 | you will notice the use of `crossProject` to define this Scala.js specific [cross-building](http://www.scala-js.org/doc/sbt/cross-building.html) project structure. 6 | 7 | Within each sub-project the usual SBT/Scala directory structure convention is followed. 8 | 9 | We'll get to the details of the project build file later on, but let's first take a look at actual client code! 10 | 11 | -------------------------------------------------------------------------------- /doc/en/autowire-and-boopickle.md: -------------------------------------------------------------------------------- 1 | # Autowire and BooPickle 2 | 3 | Web clients communicate with the server most commonly with *Ajax* which is a quite loosely defined collection of techniques. Most notable 4 | JavaScript libraries like JQuery provide higher level access to the low level protocols exposed by the browser. Scala.js provides a nice 5 | Ajax wrapper in `dom.extensions.Ajax` (or `dom.ext.Ajax` in scalajs-dom 0.8+) but it's still quite tedious to serialize/deserialize objects 6 | and take care of all the dirty little details. 7 | 8 | But fear not, there is no need to do all that yourself since our friend [Li Haoyi (lihaoyi)](https://github.com/lihaoyi) has created and 9 | published a great library called [Autowire](https://github.com/lihaoyi/autowire). Combined with my very own 10 | [BooPickle](https://github.com/ochrons/boopickle) library you can easily handle client-server communication. Note that BooPickle uses 11 | binary serialization format, so if you'd prefer a JSON format, consider using [uPickle](https://github.com/lihaoyi/upickle). As the SPA tutorial 12 | used to use uPickle for serialization, you can browse the repository history to see the relevant code 13 | [here](https://github.com/ochrons/scalajs-spa-tutorial/blob/628bf9308aaebe7f3d0527007ef604801988ef42/js/src/main/scala/spatutorial/client/services/AjaxClient.scala) 14 | and [here](https://github.com/ochrons/scalajs-spa-tutorial/blob/628bf9308aaebe7f3d0527007ef604801988ef42/jvm/src/main/scala/spatutorial/server/MainApp.scala). 15 | 16 | To build your own client-server communication pathway all you need to do is to define a single object on the client side and another on the 17 | server side. 18 | 19 | ```scala 20 | import boopickle.Default._ 21 | 22 | // client side 23 | object AjaxClient extends autowire.Client[ByteBuffer, Pickler, Pickler] { 24 | override def doCall(req: Request): Future[ByteBuffer] = { 25 | dom.ext.Ajax.post( 26 | url = "/api/" + req.path.mkString("/"), 27 | data = Pickle.intoBytes(req.args), 28 | responseType = "arraybuffer", 29 | headers = Map("Content-Type" -> "application/octet-stream") 30 | ).map(r => TypedArrayBuffer.wrap(r.response.asInstanceOf[ArrayBuffer])) 31 | } 32 | 33 | override def read[Result: Pickler](p: ByteBuffer) = Unpickle[Result].fromBytes(p) 34 | override def write[Result: Pickler](r: Result) = Pickle.intoBytes(r) 35 | } 36 | ``` 37 | 38 | The only variable specific to your application is the URL you want to use to call the server. Otherwise everything else it automatically 39 | generated for you through the magic of macros. The server side is even simpler, just letting Autowire know that you want to use BooPickle 40 | for serialization. 41 | 42 | ```scala 43 | import boopickle.Default._ 44 | 45 | // server side 46 | object Router extends autowire.Server[ByteBuffer, Pickler, Pickler] { 47 | override def read[R: Pickler](p: ByteBuffer) = Unpickle[R].fromBytes(p) 48 | override def write[R: Pickler](r: R) = Pickle.intoBytes(r) 49 | } 50 | ``` 51 | 52 | Now that you have the `AjaxClient` set up, calling the server is as simple as 53 | 54 | ```scala 55 | import scala.concurrent.ExecutionContext.Implicits.global 56 | import boopickle.Default._ 57 | import autowire._ 58 | 59 | AjaxClient[Api].getTodos().call().foreach { todos => 60 | println(s"Got some things to do $todos") 61 | } 62 | ``` 63 | 64 | Note that you need those three imports to access the Autowire/BooPickle magic and to provide an execution context for the futures. 65 | 66 | The `Api` is just a simple trait shared between the client and server. 67 | 68 | ```scala 69 | trait Api { 70 | // message of the day 71 | def motd(name:String) : String 72 | 73 | // get Todo items 74 | def getTodos() : Seq[TodoItem] 75 | 76 | // update a Todo 77 | def updateTodo(item: TodoItem): Seq[TodoItem] 78 | 79 | // delete a Todo 80 | def deleteTodo(itemId: String): Seq[TodoItem] 81 | } 82 | ``` 83 | 84 | Please check out [BooPickle documentation](https://github.com/ochrons/boopickle) on what it can and cannot serialize. You might need to use 85 | something else if your data is complicated. Case classes, base collections and basic data types are a safe bet. 86 | 87 | So how does this work on the server side? 88 | -------------------------------------------------------------------------------- /doc/en/css-in-scala.md: -------------------------------------------------------------------------------- 1 | # CSS in Scala 2 | 3 | Now that it's quite common to generate HTML in code (using Scalatags for example), why not do the same for style sheets! There are certain benefits to 4 | creating style sheets in code instead of using external CSS files. One clear benefit is to get rid of global class names that quite easily clash with 5 | each other if you're not careful. Additionally you get things like type safety, easy refactoring and IDE completion support. 6 | 7 | At this writing there are at least two separate libraries for producing CSS in Scala. One is embedded with [Scalatags](https://github.com/lihaoyi/scalatags) and 8 | the other one is a separate library called [ScalaCSS](https://github.com/japgolly/scalacss). They take a bit different approaches, so you might want to check both 9 | out and see which one fits your application better. In this tutorial we are using ScalaCSS, as it integrates nicely with scalajs-react. 10 | 11 | ## Defining global styles 12 | 13 | In our tutorial we are relying on Bootstrap to provide most of the CSS, so the global style definitions are really simple. The original CSS basically 14 | contains only one definition. 15 | 16 | ```css 17 | body { 18 | padding-top: 50px; 19 | } 20 | ``` 21 | 22 | To express this in ScalaCSS we will use the `StyleSheet.Inline` class. 23 | 24 | ```scala 25 | object GlobalStyles extends StyleSheet.Inline { 26 | import dsl._ 27 | 28 | style(unsafeRoot("body")( 29 | paddingTop(50.px)) 30 | ) 31 | } 32 | ``` 33 | 34 | For more extensive examples, please refer to [ScalaCSS documentation](https://japgolly.github.io/scalacss/book). 35 | 36 | Each call to `style` registers a new style in the internal registry. To actually generate the CSS we need in the HTML page, we have to call 37 | 38 | ```scala 39 | GlobalStyles.addToDocument() 40 | ``` 41 | 42 | in our application initialization code. Note that this is [specific initialization to scalajs-react](https://japgolly.github.io/scalacss/book/ext/react.html) 43 | and there are other methods for creating and inserting CSS in other situations. 44 | 45 | ## Wrapping external CSS 46 | 47 | As most of the styles we use are defined in Bootstrap CSS, we want to access those in a more convenient manner. Especially if at some point we would want 48 | to switch from Bootstrap to, say, [MaterializeCSS](http://materializecss.com/), it would be really nice if all the CSS class names would occur only in a single location. 49 | 50 | In Bootstrap it's very common to define a style using a base class and a contextual class, for example: 51 | 52 | ```html 53 | 54 | ``` 55 | 56 | So we'll start by defining the contextual options and some helper functions to create the style wrappers. 57 | 58 | ```scala 59 | class BootstrapStyles(implicit r: mutable.Register) extends StyleSheet.Inline()(r) { 60 | 61 | import dsl._ 62 | 63 | implicit val styleUnivEq: UnivEq[CommonStyle.Value] = new UnivEq[CommonStyle.Value] {} 64 | 65 | val csDomain = Domain.ofValues(default, primary, success, info, warning, danger) 66 | val contextDomain = Domain.ofValues(success, info, warning, danger) 67 | 68 | def commonStyle[A: UnivEq](domain: Domain[A], base: String) = styleF(domain)(opt => 69 | styleS(addClassNames(base, s"$base-$opt")) 70 | ) 71 | 72 | def styleWrap(classNames: String*) = style(addClassNames(classNames: _*)) 73 | ``` 74 | 75 | The values `default`, `primary` etc. come from an enumeration defined in the `Bootstrap.scala` component. The concept of *domain* comes from ScalaCSS 76 | [functional styles](http://japgolly.github.io/scalacss/book/features/stylef.html) and is a way of listing all possible values for a style that are generated 77 | before being used. 78 | 79 | `commonStyle` is a *functional style*, which takes as an input one value from the defined domain and returns the appropriate style. We can define all 80 | possible Bootstrap `button` styles with simply 81 | 82 | ```scala 83 | val buttonOpt = commonStyle(csDomain, "btn") 84 | val button = buttonOpt(default) 85 | ``` 86 | The default button style is defined as `button` for simple use, but if you'd need an *info* button a simple call `buttonOpt(info)` would give you that. 87 | 88 | For more straightforward Bootstrap styles we use the `styleWrap` function, which simply adds all the provided Bootstrap class names to the style. To make the 89 | use of all the various Bootstrap styles more clear, we wrap related styles under separate objects. 90 | 91 | ```scala 92 | object listGroup { 93 | val listGroup = styleWrap("list-group") 94 | val item = styleWrap("list-group-item") 95 | val itemOpt = commonStyle(contextDomain, "list-group-item") 96 | } 97 | ``` 98 | 99 | ## Using styles 100 | 101 | To use the defined inline styles in your React components, you need to `import scalacss.ScalaCssReact._` to get the relevant implicit conversions. 102 | After that it's as simple as getting a reference to your stylesheet and using the styles in your tags like below. 103 | 104 | ```scala 105 | private def bss = GlobalStyles.bootstrapStyles 106 | 107 | val style = bss.listGroup 108 | def renderItem(item: TodoItem) = { 109 | // convert priority into Bootstrap style 110 | val itemStyle = item.priority match { 111 | case TodoLow => style.itemOpt(CommonStyle.info) 112 | case TodoNormal => style.item 113 | case TodoHigh => style.itemOpt(CommonStyle.danger) 114 | } 115 | <.li(itemStyle)( 116 | <.input(^.tpe := "checkbox", ^.checked := item.completed, ^.onChange --> P.stateChange(item.copy(completed = !item.completed))), 117 | <.span(" "), 118 | if (item.completed) <.s(item.content) else <.span(item.content), 119 | Button(Button.Props(() => P.editItem(item), addStyles = Seq(bss.pullRight, bss.buttonXS)), "Edit"), 120 | Button(Button.Props(() => P.deleteItem(item), addStyles = Seq(bss.pullRight, bss.buttonXS)), "Delete") 121 | ) 122 | } 123 | <.ul(style.listGroup)(P.items toTagMod renderItem) 124 | ``` 125 | 126 | As the Bootstrap class names are "hidden" behind Scala methods, you have full IDE code completion support and there is no chance of mistyping a class name 127 | without the compiler noticing it. And the output is still identical to what you would expect: 128 | 129 | ```html 130 |