diff --git a/build.sbt b/build.sbt index 09ec531b..762438ad 100644 --- a/build.sbt +++ b/build.sbt @@ -1,12 +1,12 @@ ThisBuild / scalaVersion := "3.3.0" -wartremoverErrors ++= Warts.unsafe -wartremoverErrors ++= Warts.all +wartremoverWarnings ++= Warts.all lazy val scatan = (project in file(".")) .enablePlugins(ScalaJSPlugin) .settings( name := "Scatan", + scalaVersion := "3.3.0", libraryDependencies ++= Seq( // Do not remove or change order "org.scalatest" %%% "scalatest" % "3.3.0-SNAP4" % Test, diff --git a/src/main/scala/scatan/Main.scala b/src/main/scala/scatan/Main.scala index 5062156d..0586576d 100644 --- a/src/main/scala/scatan/Main.scala +++ b/src/main/scala/scatan/Main.scala @@ -1,23 +1,36 @@ package scatan import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom +import scatan.example.controller.{AboutController, AboutControllerImpl, HomeController, HomeControllerImpl} +import scatan.example.model.CounterAppState +import scatan.example.view.{AboutView, HomeView, ScalaJSAboutView, ScalaJsHomeView} +import scatan.mvc.lib.application.NavigableApplication +import scatan.mvc.lib.page.PageFactory +import scatan.mvc.lib.{Controller, Model, NavigableApplicationManager, ScalaJSView} -def appElement(): Element = - div( - h1("Hello Laminar!"), - div( - className := "card", - button( - className := "button", - "Click me!", - tpe := "button" +import scala.util.Random + +// Route +enum Pages(val pageFactory: PageFactory[?, ?, CounterAppState]): + case Home + extends Pages( + PageFactory[HomeController, HomeView, CounterAppState]( + viewFactory = new ScalaJsHomeView(_, "root"), + controllerFactory = new HomeControllerImpl(_) + ) + ) + case About + extends Pages( + PageFactory[AboutController, AboutView, CounterAppState]( + viewFactory = new ScalaJSAboutView(_, "root"), + controllerFactory = new AboutControllerImpl(_) + ) ) - ), - p(className := "read-the-docs", "Click on the Vite logo to learn more") - ) -@main -def main(): Unit = - val containerNode = dom.document.querySelector("#root") - // this is how you render the rootElement in the browser - render(containerNode, appElement()) +// Application +val CounterApplication: NavigableApplication[CounterAppState, Pages] = NavigableApplication[CounterAppState, Pages]( + initialState = CounterAppState(0), + pagesFactories = Pages.values.map(p => p -> p.pageFactory).toMap +) + +@main def main(): Unit = + NavigableApplicationManager.startApplication(CounterApplication, Pages.Home) diff --git a/src/main/scala/scatan/example/controller/AboutController.scala b/src/main/scala/scatan/example/controller/AboutController.scala new file mode 100644 index 00000000..3571770f --- /dev/null +++ b/src/main/scala/scatan/example/controller/AboutController.scala @@ -0,0 +1,14 @@ +package scatan.example.controller + +import scatan.example.model.CounterAppState +import scatan.example.view.AboutView +import scatan.mvc.lib.Controller + +trait AboutController extends Controller: + def about(): Unit + +class AboutControllerImpl(requirements: Controller.Requirements[AboutView, CounterAppState]) + extends AboutController + with Controller.Dependencies(requirements): + override def about(): Unit = + println("About") diff --git a/src/main/scala/scatan/example/controller/HomeController.scala b/src/main/scala/scatan/example/controller/HomeController.scala new file mode 100644 index 00000000..2a33b31c --- /dev/null +++ b/src/main/scala/scatan/example/controller/HomeController.scala @@ -0,0 +1,7 @@ +package scatan.example.controller + +import scatan.mvc.lib.Controller + +trait HomeController extends Controller: + def counter: Int + def increment(): Unit diff --git a/src/main/scala/scatan/example/controller/HomeControllerImpl.scala b/src/main/scala/scatan/example/controller/HomeControllerImpl.scala new file mode 100644 index 00000000..24c71f99 --- /dev/null +++ b/src/main/scala/scatan/example/controller/HomeControllerImpl.scala @@ -0,0 +1,17 @@ +package scatan.example.controller + +import scatan.example.model.CounterAppState +import scatan.example.view +import scatan.example.view.HomeView +import scatan.mvc.lib.{Controller, Model, ScalaJSView, View} + +class HomeControllerImpl(requirements: Controller.Requirements[HomeView, CounterAppState]) + extends HomeController + with Controller.Dependencies(requirements): + + override def counter: Int = this.model.state.count + override def increment(): Unit = + this.model.update { m => + m.copy(count = m.count + 1) + } + this.view.onCounterUpdated(this.model.state.count) diff --git a/src/main/scala/scatan/example/model/CounterAppState.scala b/src/main/scala/scatan/example/model/CounterAppState.scala new file mode 100644 index 00000000..ddd29a53 --- /dev/null +++ b/src/main/scala/scatan/example/model/CounterAppState.scala @@ -0,0 +1,5 @@ +package scatan.example.model + +import scatan.mvc.lib.Model + +case class CounterAppState(count: Int) extends Model.State diff --git a/src/main/scala/scatan/example/view/AboutView.scala b/src/main/scala/scatan/example/view/AboutView.scala new file mode 100644 index 00000000..5c57bba3 --- /dev/null +++ b/src/main/scala/scatan/example/view/AboutView.scala @@ -0,0 +1,24 @@ +package scatan.example.view + +import com.raquo.laminar.api.L.* +import scatan.example.controller.AboutController +import scatan.mvc.lib.{NavigableApplicationManager, ScalaJSView, View} + +trait AboutView extends View: + def about(): Unit + +class ScalaJSAboutView(requirements: View.Requirements[AboutController], container: String) + extends AboutView + with View.Dependencies[AboutController](requirements) + with ScalaJSView(container): + + override def about(): Unit = ??? + + override def element: Element = div( + h1("About"), + p("This is a ScalaJS view"), + button( + "Back", + onClick --> (_ => NavigableApplicationManager.navigateBack()) + ) + ) diff --git a/src/main/scala/scatan/example/view/HomeView.scala b/src/main/scala/scatan/example/view/HomeView.scala new file mode 100644 index 00000000..f37dfd77 --- /dev/null +++ b/src/main/scala/scatan/example/view/HomeView.scala @@ -0,0 +1,6 @@ +package scatan.example.view + +import scatan.mvc.lib.{ScalaJSView, View} + +trait HomeView extends View: + def onCounterUpdated(counter: Int): Unit diff --git a/src/main/scala/scatan/example/view/ScalaJsHomeView.scala b/src/main/scala/scatan/example/view/ScalaJsHomeView.scala new file mode 100644 index 00000000..8fc8e3da --- /dev/null +++ b/src/main/scala/scatan/example/view/ScalaJsHomeView.scala @@ -0,0 +1,31 @@ +package scatan.example.view + +import com.raquo.laminar.api.L.* +import scatan.Pages +import scatan.example.controller.HomeController +import scatan.mvc.lib.{NavigableApplicationManager, ScalaJSView, View} + +class ScalaJsHomeView(requirements: View.Requirements[HomeController], container: String) + extends HomeView + with View.Dependencies(requirements) + with ScalaJSView(container): + + private val reactiveCounter = Var(this.controller.counter) + + override def element: Element = + div( + h1("Scala.js Home"), + p("This is a Scala.js view"), + p("The counter is: ", child.text <-- reactiveCounter.signal), + button( + "Increment", + onClick --> (_ => controller.increment()) + ), + button( + "Switch to About Page", + onClick --> (_ => NavigableApplicationManager.navigateTo(Pages.About)) + ) + ) + + override def onCounterUpdated(counter: Int): Unit = + reactiveCounter.set(counter) diff --git a/src/main/scala/scatan/mvc/lib/Controller.scala b/src/main/scala/scatan/mvc/lib/Controller.scala new file mode 100644 index 00000000..68942e52 --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/Controller.scala @@ -0,0 +1,18 @@ +package scatan.mvc.lib + +import scatan.mvc.lib + +trait Controller + +/** The Controller object. + */ +object Controller: + type Factory[V <: View, C <: Controller, S <: Model.State] = Requirements[V, S] => C + trait Requirements[V <: View, S <: Model.State] extends Model.Provider[S] with View.Provider[V] + + trait Dependencies[V <: View, S <: Model.State](requirements: Requirements[V, S]) extends Controller: + protected def view: V = requirements.view + protected def model: Model[S] = requirements.model + + trait Provider[C <: Controller]: + def controller: C diff --git a/src/main/scala/scatan/mvc/lib/Model.scala b/src/main/scala/scatan/mvc/lib/Model.scala new file mode 100644 index 00000000..664d9acf --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/Model.scala @@ -0,0 +1,12 @@ +package scatan.mvc.lib + +trait Model[S <: Model.State](private var _state: S): + def state: S = _state + def update(f: S => S): Unit = _state = f(_state) + +object Model: + trait State + def apply[S <: State](state: S): Model[S] = new Model(state) {} + + trait Provider[S <: State]: + def model: Model[S] diff --git a/src/main/scala/scatan/mvc/lib/NavigableApplicationManager.scala b/src/main/scala/scatan/mvc/lib/NavigableApplicationManager.scala new file mode 100644 index 00000000..b478c67a --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/NavigableApplicationManager.scala @@ -0,0 +1,16 @@ +package scatan.mvc.lib + +import scatan.mvc.lib.application.{Application, NavigableApplication} + +object NavigableApplicationManager: + private var _application: Option[NavigableApplication[?, ?]] = None + + def startApplication[Route](application: NavigableApplication[?, Route], initialRoute: Route): Unit = + _application = Some(application) + application.show(initialRoute) + + def navigateTo[Route](route: Route): Unit = + _application.foreach(_.asInstanceOf[NavigableApplication[?, Route]].show(route)) + + def navigateBack(): Unit = + _application.foreach(_.back()) diff --git a/src/main/scala/scatan/mvc/lib/ScalaJSView.scala b/src/main/scala/scatan/mvc/lib/ScalaJSView.scala new file mode 100644 index 00000000..ea3ed61e --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/ScalaJSView.scala @@ -0,0 +1,17 @@ +package scatan.mvc.lib + +import org.scalajs.dom +import com.raquo.laminar.api.L.* + +trait ScalaJSView(val container: String) extends View: + def element: Element + + override def show(): Unit = + val containerElement = dom.document.getElementById(container) + containerElement.children.foreach(_.remove()) + render(containerElement, element) + + override def hide(): Unit = + val containerElement = dom.document.getElementById(container) + containerElement.children.foreach(_.remove()) + render(containerElement, div()) diff --git a/src/main/scala/scatan/mvc/lib/View.scala b/src/main/scala/scatan/mvc/lib/View.scala new file mode 100644 index 00000000..4e2e80f0 --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/View.scala @@ -0,0 +1,18 @@ +package scatan.mvc.lib + +trait View: + def show(): Unit + def hide(): Unit + +/** The View object. + */ +object View: + type Factory[C <: Controller, V <: View] = Requirements[C] => V + + trait Requirements[C <: Controller] extends Controller.Provider[C] + + trait Dependencies[C <: Controller](requirements: Requirements[C]) extends View: + protected def controller: C = requirements.controller + + trait Provider[V <: View]: + def view: V diff --git a/src/main/scala/scatan/mvc/lib/application/Application.scala b/src/main/scala/scatan/mvc/lib/application/Application.scala new file mode 100644 index 00000000..783b157b --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/application/Application.scala @@ -0,0 +1,37 @@ +package scatan.mvc.lib.application + +import scatan.mvc.lib.Model +import scatan.mvc.lib.page.{ApplicationPage, PageFactory} + +/** An application is a collection of pages that share a model. + * + * @tparam S + * The state type of the model. + * @tparam Route + * The type of the route. + */ +trait Application[S <: Model.State, Route]: + val model: Model[S] + val pages: Map[Route, ApplicationPage[S, ?, ?]] + +object Application: + /** Create an application from a model and a list of pages. + * @param initialState + * The initial state of the model. + * @param pagesFactories + * The pages. + * @tparam S + * The state type of the model. + * @tparam Route + * The type of the route. + * @return + * The application. + */ + def apply[S <: Model.State, Route]( + initialState: S, + pagesFactories: Map[Route, PageFactory[?, ?, S]] + ): Application[S, Route] = + new Application[S, Route]: + override val model: Model[S] = Model(initialState) + override val pages: Map[Route, ApplicationPage[S, ?, ?]] = + pagesFactories.map((route, pageFactory) => (route, ApplicationPage(model, pageFactory))) diff --git a/src/main/scala/scatan/mvc/lib/application/Navigable.scala b/src/main/scala/scatan/mvc/lib/application/Navigable.scala new file mode 100644 index 00000000..e4104630 --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/application/Navigable.scala @@ -0,0 +1,28 @@ +package scatan.mvc.lib.application + +import scatan.mvc.lib.* +import scatan.mvc.lib.page.{ApplicationPage, PageFactory} + +trait Navigable[Route] extends Application[?, Route]: + private var pagesHistory: Seq[Route] = Seq.empty + def show(route: Route): Unit = + pagesHistory.lastOption.foreach(this.pages(_).view.hide()) + pagesHistory = pagesHistory :+ route + pages(route).view.show() + def back(): Unit = + pagesHistory.lastOption.foreach(pages(_).view.hide()) + pagesHistory = pagesHistory.dropRight(1) + pagesHistory.lastOption.foreach(pages(_).view.show()) + +trait NavigableApplication[S <: Model.State, Route] extends Application[S, Route] with Navigable[Route] + +object NavigableApplication: + def apply[S <: Model.State, Route]( + initialState: S, + pagesFactories: Map[Route, PageFactory[?, ?, S]] + ): NavigableApplication[S, Route] = + new NavigableApplication[S, Route]: + override val model: Model[S] = Model(initialState) + override val pages: Map[Route, ApplicationPage[S, ?, ?]] = pagesFactories.map { (route, pageFactory) => + route -> ApplicationPage(this.model, pageFactory) + } diff --git a/src/main/scala/scatan/mvc/lib/page/ApplicationPage.scala b/src/main/scala/scatan/mvc/lib/page/ApplicationPage.scala new file mode 100644 index 00000000..87385d4d --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/page/ApplicationPage.scala @@ -0,0 +1,35 @@ +package scatan.mvc.lib.page + +import scatan.mvc.lib.* + +/** A page of an application. It is a combination of a model, a view and a controller. + * + * @tparam S + * The type of the state of the model. + * @tparam C + * The type of the controller. + * @tparam V + * The type of the view. + * @param model + * The model. + * @param pageFactory + * The page factory. + */ +trait ApplicationPage[S <: Model.State, C <: Controller, V <: View]( + override val model: Model[S], + val pageFactory: PageFactory[C, V, S] +) extends View.Requirements[C] + with Controller.Requirements[V, S]: + private lazy val _controller: C = pageFactory.controllerFactory(this) + private lazy val _view: V = pageFactory.viewFactory(this) + override def controller: C = _controller + override def view: V = _view + +object ApplicationPage: + type Factory[S <: Model.State, C <: Controller, V <: View] = + Model[S] => ApplicationPage[S, C, V] + def apply[S <: Model.State, C <: Controller, V <: View]( + model: Model[S], + pageFactory: PageFactory[C, V, S] + ): ApplicationPage[S, C, V] = + new ApplicationPage[S, C, V](model, pageFactory) {} diff --git a/src/main/scala/scatan/mvc/lib/page/PageFactory.scala b/src/main/scala/scatan/mvc/lib/page/PageFactory.scala new file mode 100644 index 00000000..4728bbbb --- /dev/null +++ b/src/main/scala/scatan/mvc/lib/page/PageFactory.scala @@ -0,0 +1,8 @@ +package scatan.mvc.lib.page + +import scatan.mvc.lib.{Controller, Model, View} + +case class PageFactory[C <: Controller, V <: View, S <: Model.State]( + viewFactory: View.Factory[C, V], + controllerFactory: Controller.Factory[V, C, S] +) diff --git a/src/test/scala/scatan/TestMain.scala b/src/test/scala/scatan/TestMain.scala deleted file mode 100644 index 38dc16fe..00000000 --- a/src/test/scala/scatan/TestMain.scala +++ /dev/null @@ -1,11 +0,0 @@ -package scatan - -import org.scalatest.flatspec.AnyFlatSpec - -class TestMain extends AnyFlatSpec: - - "A dummy test that" should "pass" in - assert(1 == 1) - - "Another test that" should "pass" in - assert(2 == 2) diff --git a/src/test/scala/scatan/mvc/lib/ModelTest.scala b/src/test/scala/scatan/mvc/lib/ModelTest.scala new file mode 100644 index 00000000..990d61b5 --- /dev/null +++ b/src/test/scala/scatan/mvc/lib/ModelTest.scala @@ -0,0 +1,23 @@ +package scatan.mvc.lib + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.* + +class ModelTest extends AnyFlatSpec: + + "The Model Object" should "contains the State trait" in { + val temp: Model.State = new Model.State {} + } + + it should "be creatable with a State" in { + val state: Model.State = new Model.State {} + val model: Model[Model.State] = Model(state) + model should not be null + model.state should be(state) + model.isInstanceOf[Model[?]] should be(true) + } + + it should "contains the Provider trait" in { + val temp: Model.Provider[Model.State] = new Model.Provider[Model.State]: + override def model: Model[Model.State] = Model(new Model.State {}) + }