diff --git a/.travis.yml b/.travis.yml index 23e06fa..e6922f1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ dist: trusty language: scala scala: - - 2.12.1 + - 2.12.2 jdk: - oraclejdk8 diff --git a/README.md b/README.md index cbcfb68..a60b233 100644 --- a/README.md +++ b/README.md @@ -40,19 +40,20 @@ ReactDOM.render(<.div(^.id := "hello-world")("Hello, World!"), mountNode) 2. Depend on the libraries. ``` libraryDependencies ++= Seq( - "io.github.shogowada" %%% "scalajs-reactjs" % "0.12.0", // For react facade - "io.github.shogowada" %%% "scalajs-reactjs-router-dom" % "0.12.0", // Optional. For react-router-dom facade - "io.github.shogowada" %%% "scalajs-reactjs-redux" % "0.12.0", // Optional. For react-redux facade - "io.github.shogowada" %%% "scalajs-reactjs-redux-devtools" % "0.12.0" // Optional. For redux-devtools facade + "io.github.shogowada" %%% "scalajs-reactjs" % "0.13.0", // For react facade + "io.github.shogowada" %%% "scalajs-reactjs-router-dom" % "0.13.0", // Optional. For react-router-dom facade + "io.github.shogowada" %%% "scalajs-reactjs-redux" % "0.13.0", // Optional. For react-redux facade + "io.github.shogowada" %%% "scalajs-reactjs-redux-devtools" % "0.13.0" // Optional. For redux-devtools facade ) ``` ## Examples -- [Basics](./example) -- [TODO App](./example/todo-app/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoapp/Main.scala) -- [Routing](./example/routing/src/main/scala/io/github/shogowada/scalajs/reactjs/example/routing/Main.scala) -- [Redux](./example/todo-app-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoappredux) -- [Redux DevTools](./example/redux-devtools/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/devtools/Main.scala) -- [Redux Middleware](./example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala) -- [I don't like `<` and `^`. How can I change them?](./example/custom-virtual-dom) +- [Basics](/example) +- [TODO App](/example/todo-app/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoapp/Main.scala) +- [Router](/example/routing/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/Main.scala) +- [Redux](/example/todo-app-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoappredux) +- [Redux Middleware](/example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala) +- [Router Redux](/example/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/Main.scala) +- [Redux DevTools](/example/redux-devtools/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/devtools/Main.scala) +- [I don't like `<` and `^`. How can I change them?](/example/custom-virtual-dom) diff --git a/build.sbt b/build.sbt index df35908..6d189b2 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,9 @@ val CreateReactClassVersion = "^15.5.1" +val HistoryVersion = "^4.6.1" val ReactVersion = "^15.5.3" val ReactReduxVersion = "^5.0.3" val ReactRouterVersion = "^4.0.0" +val ReactRouterReduxVersion = "next" val ReduxVersion = "^3.6.0" val ReduxDevToolsVersion = "^2.13.0" val WebpackVersion = "^2.3.2" @@ -26,7 +28,7 @@ publishArtifact := false val commonSettings = Seq( organization := "io.github.shogowada", name := "scalajs-reactjs", - version := "0.12.0", + version := "0.13.0", licenses := Seq("MIT" -> url("https://opensource.org/licenses/MIT")), homepage := Some(url("https://github.com/shogowada/scalajs-reactjs")), scalaVersion := "2.12.2", @@ -74,6 +76,19 @@ lazy val core = project.in(file("core")) ) .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) +lazy val history = project.in(file("history")) + .settings(commonSettings: _*) + .settings( + name := "scalajs-history", + npmDependencies in Compile ++= Seq( + "history" -> HistoryVersion + ), + (webpack in(Compile, fastOptJS)) := Seq(), + (webpack in(Compile, fullOptJS)) := Seq(), + publishArtifact := true + ) + .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) + lazy val router = project.in(file("router")) .settings(commonSettings: _*) .settings( @@ -86,7 +101,7 @@ lazy val router = project.in(file("router")) publishArtifact := true ) .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) - .dependsOn(core) + .dependsOn(core, history) lazy val routerDom = project.in(file("router-dom")) .settings(commonSettings: _*) @@ -131,6 +146,20 @@ lazy val reduxDevTools = project.in(file("redux-devtools")) .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) .dependsOn(core, redux) +lazy val routerRedux = project.in(file("router-redux")) + .settings(commonSettings: _*) + .settings( + name += "-router-redux", + npmDependencies in Compile ++= Seq( + "react-router-redux" -> ReactRouterReduxVersion + ), + (webpack in(Compile, fastOptJS)) := Seq(), + (webpack in(Compile, fullOptJS)) := Seq(), + publishArtifact := true + ) + .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) + .dependsOn(core, history, router, redux) + val exampleCommonSettings = commonSettings ++ Seq( name += "-example", (unmanagedResourceDirectories in Compile) += baseDirectory.value / "src" / "main" / "webapp" @@ -192,14 +221,22 @@ lazy val exampleReduxMiddleware = project.in(file("example") / "redux-middleware .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) .dependsOn(core, redux, reduxDevTools) -lazy val exampleRouting = project.in(file("example") / "routing") +lazy val exampleRouter = project.in(file("example") / "router") .settings(exampleCommonSettings: _*) .settings( - name += "-routing" + name += "-router" ) .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) .dependsOn(core, routerDom) +lazy val exampleRouterRedux = project.in(file("example") / "router-redux") + .settings(exampleCommonSettings: _*) + .settings( + name += "-router-redux" + ) + .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin) + .dependsOn(core, redux, routerDom, routerRedux, reduxDevTools) + lazy val exampleStyle = project.in(file("example") / "style") .settings(exampleCommonSettings: _*) .settings( @@ -244,7 +281,8 @@ lazy val exampleTest = project.in(file("example") / "test") s"-Dtarget.path.lifecycle=${(crossTarget in exampleLifecycle).value}", s"-Dtarget.path.redux-devtools=${(crossTarget in exampleReduxDevTools).value}", s"-Dtarget.path.redux-middleware=${(crossTarget in exampleReduxMiddleware).value}", - s"-Dtarget.path.routing=${(crossTarget in exampleRouting).value}", + s"-Dtarget.path.router=${(crossTarget in exampleRouter).value}", + s"-Dtarget.path.router-redux=${(crossTarget in exampleRouterRedux).value}", s"-Dtarget.path.style=${(crossTarget in exampleStyle).value}", s"-Dtarget.path.todo-app=${(crossTarget in exampleTodoApp).value}", s"-Dtarget.path.todo-app-redux=${(crossTarget in exampleTodoAppRedux).value}", @@ -256,7 +294,8 @@ lazy val exampleTest = project.in(file("example") / "test") (webpack in fastOptJS in Compile in exampleLifecycle).value, (webpack in fastOptJS in Compile in exampleReduxDevTools).value, (webpack in fastOptJS in Compile in exampleReduxMiddleware).value, - (webpack in fastOptJS in Compile in exampleRouting).value, + (webpack in fastOptJS in Compile in exampleRouter).value, + (webpack in fastOptJS in Compile in exampleRouterRedux).value, (webpack in fastOptJS in Compile in exampleStyle).value, (webpack in fastOptJS in Compile in exampleTodoApp).value, (webpack in fastOptJS in Compile in exampleTodoAppRedux).value diff --git a/example/README.md b/example/README.md index cd8cc10..275fe41 100644 --- a/example/README.md +++ b/example/README.md @@ -148,7 +148,8 @@ def onChange(self: Self[Unit, State]) = // Use a higher-order function (a functi Sure, you can! All the projects in this folder are fully working examples, but here are some top picks: - [TODO App](/example/todo-app/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoapp/Main.scala) -- [Routing](/example/routing/src/main/scala/io/github/shogowada/scalajs/reactjs/example/routing/Main.scala) +- [Router](/example/routing/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/Main.scala) - [Redux](/example/todo-app-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoappredux) +- [Redux Middleware](/example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala) +- [Router Redux](/example/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/Main.scala) - [Redux DevTools](/example/redux-devtools/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/devtools/Main.scala) -- [Redux Middleware](./example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala) diff --git a/example/redux-devtools/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/devtools/Main.scala b/example/redux-devtools/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/devtools/Main.scala index 617bbc5..1d5ebf7 100644 --- a/example/redux-devtools/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/devtools/Main.scala +++ b/example/redux-devtools/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/devtools/Main.scala @@ -35,7 +35,7 @@ case class State(text: String) case class SetText(text: String) extends Action object Reducer { - def apply(maybeState: Option[State], action: Action): State = + def apply(maybeState: Option[State], action: Any): State = action match { case action: SetText => State(text = action.text) case _ => State(text = "") diff --git a/example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala b/example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala index dd4b595..275d7c2 100644 --- a/example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala +++ b/example/redux-middleware/src/main/scala/io/github/shogowada/scalajs/reactjs/example/redux/middleware/Main.scala @@ -32,14 +32,14 @@ case class Snapshot(snapshot: Int) extends Action case class Error(throwable: Throwable) extends Action object Reducer { - def reduce(maybeState: Option[State], action: Action): State = + def reduce(maybeState: Option[State], action: Any): State = State( result = reduceResult(maybeState.map(_.result), action), snapshot = reduceSnapshot(maybeState.flatMap(_.snapshot), action), error = reduceError(maybeState.flatMap(_.error), action) ) - def reduceResult(maybeResult: Option[Int], action: Action): Int = { + def reduceResult(maybeResult: Option[Int], action: Any): Int = { val result = maybeResult.getOrElse(0) action match { case action: Add => result + action.value @@ -48,14 +48,14 @@ object Reducer { } } - def reduceSnapshot(maybeSnapshot: Option[Int], action: Action): Option[Int] = { + def reduceSnapshot(maybeSnapshot: Option[Int], action: Any): Option[Int] = { action match { case action: Snapshot => Option(action.snapshot) case _ => maybeSnapshot } } - def reduceError(maybeError: Option[Throwable], action: Action): Option[Throwable] = { + def reduceError(maybeError: Option[Throwable], action: Any): Option[Throwable] = { action match { case action: Error => Option(action.throwable) case _ => maybeError diff --git a/example/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/Main.scala b/example/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/Main.scala new file mode 100644 index 0000000..011e00d --- /dev/null +++ b/example/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/Main.scala @@ -0,0 +1,197 @@ +package io.github.shogowada.scalajs.reactjs.example.router.redux + +import io.github.shogowada.scalajs.history.History +import io.github.shogowada.scalajs.reactjs.VirtualDOM._ +import io.github.shogowada.scalajs.reactjs.events.{FormSyntheticEvent, SyntheticEvent} +import io.github.shogowada.scalajs.reactjs.redux.ReactRedux._ +import io.github.shogowada.scalajs.reactjs.redux.Redux.Dispatch +import io.github.shogowada.scalajs.reactjs.redux.devtools.ReduxDevTools +import io.github.shogowada.scalajs.reactjs.redux.{Action, NativeAction, ReactRedux, Redux} +import io.github.shogowada.scalajs.reactjs.router.Router._ +import io.github.shogowada.scalajs.reactjs.router.RouterProps +import io.github.shogowada.scalajs.reactjs.router.redux.ReactRouterRedux +import io.github.shogowada.scalajs.reactjs.router.redux.ReactRouterRedux._ +import io.github.shogowada.scalajs.reactjs.router.redux.ReactRouterReduxAction.{Go, GoBack, GoForward, Push} +import io.github.shogowada.scalajs.reactjs.{React, ReactDOM} +import org.scalajs.dom +import org.scalajs.dom.raw.HTMLInputElement + +import scala.scalajs.js +import scala.scalajs.js.JSApp + +/* +* If you are not yet familiar with react-router-redux, +* see https://github.com/ReactTraining/react-router/tree/master/packages/react-router-redux for details. +* */ + +/* +* Because we are sharing the Redux state with react-router-redux, +* your state needs to be wrapped. +* */ +@js.native +trait State extends js.Object { + def wrapped: WrappedState = js.native +} + +case class WrappedState(textA: String, textB: String) + +case class ChangeTextA(text: String) extends Action +case class ChangeTextB(text: String) extends Action + +object Reducer { + val reduce = (maybeState: Option[WrappedState], action: Any) => + WrappedState( + textA = reduceTextA(maybeState.map(_.textA), action), + textB = reduceTextB(maybeState.map(_.textB), action) + ) + + def reduceTextA(maybeTextA: Option[String], action: Any): String = + action match { + case action: ChangeTextA => action.text + case _ => maybeTextA.getOrElse("") + } + + def reduceTextB(maybeTextB: Option[String], action: Any): String = + action match { + case action: ChangeTextB => action.text + case _ => maybeTextB.getOrElse("") + } +} + +object Main extends JSApp { + override def main(): Unit = { + /* + * You can use one of the following histories: + * + * - History.createBrowserHistory() + * - History.createHashHistory() + * - History.createMemoryHistory() + * + * See https://www.npmjs.com/package/history for details. + * */ + val history = History.createHashHistory() + + val store = Redux.createStore( + /* + * Combine your reducer with RouterRedux.routerReducer. + * Note that state of the router needs to be named "router". + * */ + Redux.combineReducers(Map( + "wrapped" -> Reducer.reduce, + "router" -> ReactRouterRedux.routerReducer + )), + ReduxDevTools.composeWithDevTools( // DevTools is optional + Redux.applyMiddleware( + ReactRouterRedux.routerMiddleware(history) + ) + ) + ) + + /* + * To access required elements and attributes, import the following: + * + * - io.github.shogowada.scalajs.reactjs.VirtualDOM._ + * - For general elements and attributes + * - io.github.shogowada.scalajs.reactjs.redux.ReactRedux._ + * - For Provider and store + * - io.github.shogowada.scalajs.reactjs.router.Router._ + * - For history, Switch, Route, and others + * - io.github.shogowada.scalajs.reactjs.router.redux.RouterRedux._ + * - For ConnectedRouter + * */ + val mountNode = dom.document.getElementById("mount-node") + ReactDOM.render( + <.Provider(^.store := store)( + <.ConnectedRouter(^.history := history)( + <.Route(^.component := RouteControllerComponent.reactClass)() + ) + ), + mountNode + ) + } +} + +object RouteControllerComponent { + lazy val reactClass = ReactRedux.connectAdvanced( + (dispatch: Dispatch) => { + val act = (action: NativeAction) => dispatch(action) + (state: State, ownProps: Unit) => { + RouteControllerPresentationalComponent.WrappedProps(act) + } + } + )(RouteControllerPresentationalComponent.reactClass) +} + +object RouteControllerPresentationalComponent extends RouterProps { + case class WrappedProps(act: (NativeAction) => _) + + type Self = React.Self[WrappedProps, Unit] + + lazy val reactClass = React.createClass[WrappedProps, Unit]( + (self) => + <.div()( + <.h3()("Path: ", <.span(^.id := "path")(self.props.location.pathname)), + <.div()( + <.button(^.id := "push-route-a", ^.onClick := act(self, Push("/a")))("Push route A"), + <.button(^.id := "push-route-b", ^.onClick := act(self, Push("/b")))("Push route B"), + <.button(^.id := "go-negative-3", ^.onClick := act(self, Go(-3)))("Go -3"), + <.button(^.id := "go-back", ^.onClick := act(self, GoBack()))("Go back"), + <.button(^.id := "go-forward", ^.onClick := act(self, GoForward()))("Go forward") + ), + <.Switch()( + <.Route(^.path := "/a", ^.component := ARouteComponent.reactClass)(), + <.Route(^.path := "/b", ^.component := BRouteComponent.reactClass)() + ) + ) + ) + + private def act(self: Self, action: NativeAction) = + (event: SyntheticEvent) => { + event.preventDefault() + self.props.wrapped.act(action) + } +} + +object ARouteComponent { + lazy val reactClass = ReactRedux.connectAdvanced( + (dispatch: Dispatch) => { + val onTextChange = (text: String) => dispatch(ChangeTextA(text)) + (state: State, ownProps: Unit) => { + RoutePresentationalComponent.WrappedProps( + text = state.wrapped.textA, + onTextChange = onTextChange + ) + } + } + )(RoutePresentationalComponent.reactClass) +} + +object BRouteComponent { + lazy val reactClass = ReactRedux.connectAdvanced( + (dispatch: Dispatch) => { + val onTextChange = (text: String) => dispatch(ChangeTextB(text)) + (state: State, ownProps: Unit) => { + RoutePresentationalComponent.WrappedProps( + text = state.wrapped.textB, + onTextChange = onTextChange + ) + } + } + )(RoutePresentationalComponent.reactClass) +} + +object RoutePresentationalComponent { + + case class WrappedProps(text: String, onTextChange: (String) => _) + + lazy val reactClass = React.createClass[WrappedProps, Unit]( + (self) => + <.input( + ^.value := self.props.wrapped.text, + ^.onChange := ((event: FormSyntheticEvent[HTMLInputElement]) => { + val text = event.target.value + self.props.wrapped.onTextChange(text) + }) + )() + ) +} diff --git a/example/router-redux/src/main/webapp/index.html b/example/router-redux/src/main/webapp/index.html new file mode 100644 index 0000000..6af4f5a --- /dev/null +++ b/example/router-redux/src/main/webapp/index.html @@ -0,0 +1,6 @@ + + +
+ + + diff --git a/example/routing/src/main/scala/io/github/shogowada/scalajs/reactjs/example/routing/Main.scala b/example/router/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/Main.scala similarity index 98% rename from example/routing/src/main/scala/io/github/shogowada/scalajs/reactjs/example/routing/Main.scala rename to example/router/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/Main.scala index b146fe1..be0a3f9 100644 --- a/example/routing/src/main/scala/io/github/shogowada/scalajs/reactjs/example/routing/Main.scala +++ b/example/router/src/main/scala/io/github/shogowada/scalajs/reactjs/example/router/Main.scala @@ -1,4 +1,4 @@ -package io.github.shogowada.scalajs.reactjs.example.routing +package io.github.shogowada.scalajs.reactjs.example.router import io.github.shogowada.scalajs.reactjs.React.{Props, Self} import io.github.shogowada.scalajs.reactjs.VirtualDOM._ diff --git a/example/routing/src/main/webapp/index.html b/example/router/src/main/webapp/index.html similarity index 78% rename from example/routing/src/main/webapp/index.html rename to example/router/src/main/webapp/index.html index 9565559..4c7c9e9 100644 --- a/example/routing/src/main/webapp/index.html +++ b/example/router/src/main/webapp/index.html @@ -1,6 +1,6 @@
- + diff --git a/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/TestTargetServer.scala b/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/TestTargetServer.scala index 8ac036d..198782f 100644 --- a/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/TestTargetServer.scala +++ b/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/TestTargetServer.scala @@ -54,7 +54,8 @@ object TestTargetServers { val lifecycle = new TestTargetServer("lifecycle") val reduxDevTools = new TestTargetServer("redux-devtools") val reduxMiddleware = new TestTargetServer("redux-middleware") - val routing = new TestTargetServer("routing") + val router = new TestTargetServer("router") + val routerRedux = new TestTargetServer("router-redux") val style = new TestTargetServer("style") val todoApp = new TestTargetServer("todo-app") val todoAppRedux = new TestTargetServer("todo-app-redux") @@ -66,7 +67,8 @@ object TestTargetServers { lifecycle.start() reduxDevTools.start() reduxMiddleware.start() - routing.start() + routerRedux.start() + router.start() style.start() todoApp.start() todoAppRedux.start() @@ -79,7 +81,8 @@ object TestTargetServers { lifecycle.stop() reduxDevTools.stop() reduxMiddleware.stop() - routing.stop() + routerRedux.stop() + router.stop() style.stop() todoApp.stop() todoAppRedux.stop() diff --git a/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/routing/RoutingTest.scala b/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/router/RouterTest.scala similarity index 96% rename from example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/routing/RoutingTest.scala rename to example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/router/RouterTest.scala index 8827a8b..52f475e 100644 --- a/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/routing/RoutingTest.scala +++ b/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/router/RouterTest.scala @@ -1,13 +1,13 @@ -package io.github.shogowada.scalajs.reactjs.example.routing +package io.github.shogowada.scalajs.reactjs.example.router import java.util.regex.{Matcher, Pattern} import io.github.shogowada.scalajs.reactjs.example.{BaseTest, TestTargetServers} import org.openqa.selenium.Alert -class RoutingTest extends BaseTest { +class RouterTest extends BaseTest { - val server = TestTargetServers.routing + val server = TestTargetServers.router "given I am at home page" - { go to server.host diff --git a/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/RouterReduxTest.scala b/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/RouterReduxTest.scala new file mode 100644 index 0000000..199acce --- /dev/null +++ b/example/test/src/it/scala/io/github/shogowada/scalajs/reactjs/example/router/redux/RouterReduxTest.scala @@ -0,0 +1,99 @@ +package io.github.shogowada.scalajs.reactjs.example.router.redux + +import io.github.shogowada.scalajs.reactjs.example.{BaseTest, TestTargetServers} + +class RouterReduxTest extends BaseTest { + val server = TestTargetServers.routerRedux + + "given I am on the page" - { + go to server.host + + "when I push route A" - { + pushRouteA() + + thenIShouldBeOnRouteA() + + "and I put text" - { + val textA = "text A" + textField(tagName("input")).value = textA + + thenItShouldDisplay(textA) + + "when I push route B" - { + pushRouteB() + + thenIShouldBeOnRouteB() + + thenItShouldDisplay("") + + "and I put text" - { + val textB = "text B" + textField(tagName("input")).value = textB + + thenItShouldDisplay(textB) + + "when I push route A again" - { + pushRouteA() + + thenIShouldBeOnRouteA() + thenItShouldDisplay(textA) + + "when I push route B again" - { + pushRouteB() + + thenIShouldBeOnRouteB() + thenItShouldDisplay(textB) + + "when I go -3" - { + clickOn(id("go-negative-3")) + + thenIShouldBeOnRouteA() + thenItShouldDisplay(textA) + } + } + } + } + + "when I go back" - { + clickOn(id("go-back")) + + thenIShouldBeOnRouteA() + + "when I go forward" - { + clickOn(id("go-forward")) + + thenIShouldBeOnRouteB() + } + } + } + } + } + } + + def pushRouteA(): Unit = clickOn(id("push-route-a")) + def pushRouteB(): Unit = clickOn(id("push-route-b")) + + def thenItShouldDisplay(text: String): Unit = { + "then it should display" in { + eventually { + textField(tagName("input")).value should equal(text) + } + } + } + + def thenIShouldBeOnRouteA(): Unit = { + "then I should be on route A" in { + eventually { + find(id("path")).get.text should equal("/a") + } + } + } + + def thenIShouldBeOnRouteB(): Unit = { + "then I should be on route B" in { + eventually { + find(id("path")).get.text should equal("/b") + } + } + } +} diff --git a/example/todo-app-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoappredux/Reducer.scala b/example/todo-app-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoappredux/Reducer.scala index 84e82e5..3a2c725 100644 --- a/example/todo-app-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoappredux/Reducer.scala +++ b/example/todo-app-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/example/todoappredux/Reducer.scala @@ -1,19 +1,17 @@ package io.github.shogowada.scalajs.reactjs.example.todoappredux -import io.github.shogowada.scalajs.reactjs.redux.Action - /* * Reducer function has a signature of (Option[State], Action) => State. * If the state is absent, return an initial state. * */ object Reducer { - def reduce(maybeState: Option[State], action: Action): State = + def reduce(maybeState: Option[State], action: Any): State = State( todos = reduceTodos(maybeState.map(_.todos), action), visibilityFilter = reduceVisibilityFilter(maybeState.map(_.visibilityFilter), action) ) - private def reduceTodos(maybeTodos: Option[Seq[TodoItem]], action: Action): Seq[TodoItem] = { + private def reduceTodos(maybeTodos: Option[Seq[TodoItem]], action: Any): Seq[TodoItem] = { val todos = maybeTodos.getOrElse(Seq.empty) action match { case action: AddTodo => { @@ -34,7 +32,7 @@ object Reducer { } } - private def reduceVisibilityFilter(maybeVisibilityFilter: Option[String], action: Action): String = + private def reduceVisibilityFilter(maybeVisibilityFilter: Option[String], action: Any): String = action match { case action: SetVisibilityFilter => action.filter case _ => maybeVisibilityFilter.getOrElse(VisibilityFilters.ShowAll) diff --git a/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/History.scala b/history/src/main/scala/io/github/shogowada/scalajs/history/History.scala similarity index 52% rename from router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/History.scala rename to history/src/main/scala/io/github/shogowada/scalajs/history/History.scala index 6f97dcf..7858230 100644 --- a/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/History.scala +++ b/history/src/main/scala/io/github/shogowada/scalajs/history/History.scala @@ -1,4 +1,4 @@ -package io.github.shogowada.scalajs.reactjs.router +package io.github.shogowada.scalajs.history import scala.scalajs.js import scala.scalajs.js.annotation.JSImport @@ -12,3 +12,11 @@ trait History extends js.Object { def goBack(): Unit = js.native def goForward(): Unit = js.native } + +@js.native +@JSImport("history", JSImport.Namespace) +object History extends js.Object { + def createBrowserHistory(): History = js.native + def createHashHistory(): History = js.native + def createMemoryHistory(): History = js.native +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 973d7bd..1a542d0 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,4 @@ -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.15") -addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.5.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.16") +addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.6.0") addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") diff --git a/redux/src/main/scala/io/github/shogowada/scalajs/reactjs/redux/Redux.scala b/redux/src/main/scala/io/github/shogowada/scalajs/reactjs/redux/Redux.scala index 94be9e9..14d36c3 100644 --- a/redux/src/main/scala/io/github/shogowada/scalajs/reactjs/redux/Redux.scala +++ b/redux/src/main/scala/io/github/shogowada/scalajs/reactjs/redux/Redux.scala @@ -3,68 +3,55 @@ package io.github.shogowada.scalajs.reactjs.redux import io.github.shogowada.scalajs.reactjs.redux.Redux._ import scala.scalajs.js +import scala.scalajs.js.JSConverters._ import scala.scalajs.js.annotation.JSImport -object Action { - def actionFromNative(nativeAction: js.Dynamic): Option[Action] = - if (js.isUndefined(nativeAction.wrapped)) { - None - } else { - nativeAction.wrapped match { - case action: Action => Option(action) - case _ => None - } - } +trait Action - def actionToNative(action: Any): js.Dynamic = action match { - case action: Action => js.Dynamic.literal( - "type" -> action.getClass.getSimpleName, - "wrapped" -> action.asInstanceOf[js.Any] - ) - case _ => action.asInstanceOf[js.Dynamic] - } +@js.native +trait NativeAction extends js.Object { + val `type`: String } -trait Action - @js.native trait NativeStore extends js.Object { def getState(): js.Object = js.native - def dispatch(action: js.Dynamic): js.Dynamic = js.native + def dispatch(action: js.Any): js.Any = js.native } case class Store[State](native: NativeStore) { - def getState: State = native.asInstanceOf[js.Dynamic].getState().asInstanceOf[State] - def dispatch(action: Any): Any = native.dispatch(Action.actionToNative(action)) + def getState: State = native.getState().asInstanceOf[State] + def dispatch(action: Any): Any = native.dispatch(ReduxInternal.actionToNative(action)) } object Redux { - type NativeReducer = js.Function2[js.Object, js.Dynamic, js.Object] + type NativeReducer = js.Function2[js.Any, NativeAction, js.Any] type NativeEnhancer = js.Any - type NativeDispatch = js.Function1[js.Dynamic, js.Dynamic] - type NativeMiddleware = js.Function1[NativeStore, js.Function1[NativeDispatch, js.Function1[js.Object, _]]] + type NativeDispatch = js.Function1[js.Any, js.Any] + type NativeMiddleware = js.Function1[NativeStore, js.Function1[NativeDispatch, js.Function1[js.Any, _]]] + type Reducer[State] = (Option[State], Any) => State type Dispatch = Any => Any type Middleware[State] = (Store[State]) => (Dispatch) => Any => _ - def createStore[State](reducer: (Option[State], Action) => State): Store[State] = + def combineReducers(reducers: Map[String, (_, Action) => _]): Reducer[js.Object] = { + val nativeReducer: NativeReducer = NativeRedux.combineReducers( + reducers.map { case (key, value) => + key -> ReduxInternal.reducerToNative(value.asInstanceOf[Reducer[_]]) + }.toJSDictionary + ) + ReduxInternal.reducerFromNative[js.Object](nativeReducer) + } + + def createStore[State](reducer: Reducer[State]): Store[State] = createStore(reducer, null) def createStore[State]( - reducer: (Option[State], Action) => State, + reducer: Reducer[State], enhancer: NativeEnhancer ): Store[State] = { - val nativeStore = NativeRedux.createStore((state: js.Object, nativeAction: js.Dynamic) => { - if (js.isUndefined(state) || state == null) { - reducer(None, null).asInstanceOf[js.Object] - } else { - Action.actionFromNative(nativeAction) match { - case Some(action: Action) => - reducer(Some(state.asInstanceOf[State]), action).asInstanceOf[js.Object] - case _ => state - } - } - }, enhancer) + val nativeReducer = ReduxInternal.reducerToNative(reducer) + val nativeStore = NativeRedux.createStore(nativeReducer, enhancer) Store(nativeStore) } @@ -75,13 +62,58 @@ object Redux { } object ReduxInternal { + def actionToNative(action: Any): NativeAction = + action match { + case action: Action => js.Dynamic.literal( + "type" -> action.getClass.getSimpleName, + "wrapped" -> action.asInstanceOf[js.Any] + ).asInstanceOf[NativeAction] + case _ => action.asInstanceOf[NativeAction] + } + + def actionFromNative(nativeAction: js.Any): Option[Action] = { + val dynamicAction = nativeAction.asInstanceOf[js.Dynamic] + if (js.isUndefined(dynamicAction.wrapped)) { + None + } else { + dynamicAction.wrapped match { + case action: Action => Option(action) + case _ => None + } + } + } + + def reducerToNative[State](reducer: Reducer[State]): NativeReducer = { + (state: js.Any, nativeAction: NativeAction) => { + val maybeState: Option[State] = Option(state) + .filterNot(js.isUndefined) + .map(_.asInstanceOf[State]) + + actionFromNative(nativeAction) match { + case Some(action: Action) => reducer(maybeState, action).asInstanceOf[js.Any] + case _ => reducer(maybeState, nativeAction).asInstanceOf[js.Any] + } + } + } + + def reducerFromNative[State](native: NativeReducer): Reducer[State] = { + (maybeState: Option[State], action: Any) => { + val nativeAction = actionToNative(action) + val nativeState = maybeState match { + case Some(state) => native(state.asInstanceOf[js.Object], nativeAction) + case None => native(js.undefined, nativeAction) + } + nativeState.asInstanceOf[State] + } + } + def middlewareToNative[State](middleware: Middleware[State]): NativeMiddleware = { (nativeStore: NativeStore) => { val middleware2 = middleware(Store(nativeStore)) (nativeDispatch: NativeDispatch) => { - val middleware3 = middleware2(ReduxInternal.dispatchFromNative(nativeDispatch)) - (nativeAction: js.Object) => { - Action.actionFromNative(nativeAction.asInstanceOf[js.Dynamic]) match { + val middleware3 = middleware2(dispatchFromNative(nativeDispatch)) + (nativeAction: js.Any) => { + actionFromNative(nativeAction) match { case Some(action: Action) => middleware3(action) case None => middleware3(nativeAction) } @@ -90,18 +122,44 @@ object ReduxInternal { } } + def middlewareFromNative[State](middleware: NativeMiddleware): Middleware[State] = { + (store: Store[State]) => { + val middleware2 = middleware(store.native) + (dispatch: Dispatch) => { + val middleware3 = middleware2(dispatchToNative(dispatch)) + (action: Any) => { + middleware3(actionToNative(action)) + } + } + } + } + + def dispatchToNative(dispatch: Dispatch): NativeDispatch = + (nativeAction: js.Any) => { + val result = actionFromNative(nativeAction) match { + case Some(action: Action) => dispatch(action) + case None => dispatch(nativeAction) + } + if (js.isUndefined(result) || result == null) { + js.undefined + } else { + result.asInstanceOf[js.Any] + } + } + def dispatchFromNative(nativeDispatch: NativeDispatch): Dispatch = (action: Any) => action match { case action: Action => - val nativeAction = Action.actionToNative(action) + val nativeAction = actionToNative(action) nativeDispatch(nativeAction) - case _ => nativeDispatch(action.asInstanceOf[js.Dynamic]) + case _ => nativeDispatch(action.asInstanceOf[js.Any]) } } @js.native @JSImport("redux", JSImport.Namespace) object NativeRedux extends js.Object { + def combineReducers(reducers: js.Dictionary[NativeReducer]): NativeReducer = js.native def createStore(reducer: NativeReducer, enhancer: NativeEnhancer): NativeStore = js.native def applyMiddleware(middlewares: NativeMiddleware*): NativeEnhancer = js.native } diff --git a/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/router/redux/ReactRouterRedux.scala b/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/router/redux/ReactRouterRedux.scala new file mode 100644 index 0000000..31510b7 --- /dev/null +++ b/router-redux/src/main/scala/io/github/shogowada/scalajs/reactjs/router/redux/ReactRouterRedux.scala @@ -0,0 +1,53 @@ +package io.github.shogowada.scalajs.reactjs.router.redux + +import io.github.shogowada.scalajs.history.History +import io.github.shogowada.scalajs.reactjs.VirtualDOM.VirtualDOMElements +import io.github.shogowada.scalajs.reactjs.VirtualDOM.VirtualDOMElements.ReactClassElementSpec +import io.github.shogowada.scalajs.reactjs.classes.ReactClass +import io.github.shogowada.scalajs.reactjs.redux.Redux.{Middleware, NativeMiddleware, Reducer} +import io.github.shogowada.scalajs.reactjs.redux.{NativeAction, ReduxInternal} + +import scala.scalajs.js +import scala.scalajs.js.annotation.{JSImport, JSName} + +@js.native +@JSImport("react-router-redux", JSImport.Namespace) +object ReactRouterReduxAction extends js.Object { + @JSName("push") + def Push(path: String): NativeAction = js.native + @JSName("replace") + def Replace(path: String): NativeAction = js.native + @JSName("go") + def Go(delta: Int): NativeAction = js.native + @JSName("goBack") + def GoBack(): NativeAction = js.native + @JSName("goForward") + def GoForward(): NativeAction = js.native +} + +object ReactRouterRedux { + implicit class RouterReduxVirtualDOMElements(elements: VirtualDOMElements) { + lazy val ConnectedRouter = ReactClassElementSpec(NativeReactRouterRedux.ConnectedRouter) + } + + val routerReducer: Reducer[js.Object] = + (state: Option[_], action: Any) => { + val nativeState: js.Object = state.map(_.asInstanceOf[js.Object]).getOrElse(js.Object()) + val nativeAction = ReduxInternal.actionToNative(action) + NativeReactRouterRedux.routerReducer(nativeState, nativeAction) + } + + def routerMiddleware[State](history: History): Middleware[State] = + ReduxInternal.middlewareFromNative( + NativeReactRouterRedux.routerMiddleware(history) + ) +} + +@js.native +@JSImport("react-router-redux", JSImport.Namespace) +object NativeReactRouterRedux extends js.Object { + val ConnectedRouter: ReactClass = js.native + + def routerReducer(routerState: js.Object, action: NativeAction): js.Object = js.native + def routerMiddleware(history: History): NativeMiddleware = js.native +} diff --git a/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/Router.scala b/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/Router.scala index 9e45511..0ae9d90 100644 --- a/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/Router.scala +++ b/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/Router.scala @@ -1,5 +1,6 @@ package io.github.shogowada.scalajs.reactjs.router +import io.github.shogowada.scalajs.history.History import io.github.shogowada.scalajs.reactjs.VirtualDOM.VirtualDOMAttributes.Type.AS_IS import io.github.shogowada.scalajs.reactjs.VirtualDOM.VirtualDOMAttributes.{ReactClassAttributeSpec, RenderAttributeSpec} import io.github.shogowada.scalajs.reactjs.VirtualDOM.VirtualDOMElements.ReactClassElementSpec diff --git a/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/RouterProps.scala b/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/RouterProps.scala index ffae735..8816233 100644 --- a/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/RouterProps.scala +++ b/router/src/main/scala/io/github/shogowada/scalajs/reactjs/router/RouterProps.scala @@ -1,5 +1,6 @@ package io.github.shogowada.scalajs.reactjs.router +import io.github.shogowada.scalajs.history.History import io.github.shogowada.scalajs.reactjs.React.Props trait RouterProps {