Skip to content

Commit

Permalink
Frontend Routing (#197)
Browse files Browse the repository at this point in the history
* Move ready() implementation up to IO/ZIO level

* Extending MultiPage give hash based routing

* Make TyrianRoutedAppF methods private

* Popstate based routing

* Remove Navigation class

* Introduce a location class

* Add deprecation warning for TyrianApp

* Add a random hash link to the sandbox

* Paranoid save - works, contains TODOs

* Added an external link to the sandbox, which errors.

* Added broken Location tests, ready to fix!

* WIP: LocationDetails

* LocationDetails path matching

* LocationDetails works

* Location type working

* Clean up

* Improve sandbox example

* Only pushstate on internal links

* Formatting

* Restore TyrianApp now includes routing requirement

* Updating Routing

* Fixed scaladocs

* Added an `all` Router + more scaladocs

* Nav forward and back + scaladocs
  • Loading branch information
davesmith00000 authored May 16, 2023
1 parent af1edf0 commit cf8772d
Show file tree
Hide file tree
Showing 15 changed files with 1,034 additions and 105 deletions.
29 changes: 22 additions & 7 deletions indigo-sandbox/src/main/scala/example/IndigoSandbox.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,18 @@ object IndigoSandbox extends TyrianApp[Msg, Model]:
val gameId1: IndigoGameId = IndigoGameId("reverse")
val gameId2: IndigoGameId = IndigoGameId("combine")

def router: Location => Msg = Routing.externalOnly(Msg.NoOp, Msg.FollowLink(_))

def init(flags: Map[String, String]): (Model, Cmd[Task, Msg]) =
(Model.init, Cmd.Emit(Msg.StartIndigo))

def update(model: Model): Msg => (Model, Cmd[Task, Msg]) =
case Msg.NoOp =>
(model, Cmd.None)

case Msg.FollowLink(href) =>
(model, Nav.loadUrl(href))

case Msg.NewRandomInt(i) =>
(model.copy(randomNumber = i), Cmd.None)

Expand Down Expand Up @@ -90,6 +98,11 @@ object IndigoSandbox extends TyrianApp[Msg, Model]:

div(
div(hidden(false))("Random number: " + model.randomNumber.toString),
div(
a(href := "/another-page")("Internal link (will be ignored)"),
br,
a(href := "http://tyrian.indigoengine.io/")("Tyrian website")
),
div(id := gameDivId1)(),
div(id := gameDivId2)(),
div(
Expand Down Expand Up @@ -122,13 +135,15 @@ object IndigoSandbox extends TyrianApp[Msg, Model]:
)

enum Msg:
case NewContent(content: String) extends Msg
case Insert extends Msg
case Remove extends Msg
case Modify(i: Int, msg: Counter.Msg) extends Msg
case StartIndigo extends Msg
case IndigoReceive(msg: String) extends Msg
case NewRandomInt(i: Int) extends Msg
case NewContent(content: String)
case Insert
case Remove
case Modify(i: Int, msg: Counter.Msg)
case StartIndigo
case IndigoReceive(msg: String)
case NewRandomInt(i: Int)
case FollowLink(href: String)
case NoOp

object Counter:

Expand Down
79 changes: 49 additions & 30 deletions sandbox/src/main/scala/example/Sandbox.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,32 @@ import tyrian.websocket.*

import scala.concurrent.duration.*
import scala.scalajs.js.annotation.*
import scala.util.Random

import scalajs.js

@JSExportTopLevel("TyrianApp")
object Sandbox extends TyrianApp[Msg, Model]:

// Here we just do a simple string match, but this could be a route matching
// lib like: https://github.com/sherpal/url-dsl
def router: Location => Msg =
case loc: Location.Internal =>
loc.pathName match
case "/" => Msg.NavigateTo(Page.Page1)
case "/page1" => Msg.NavigateTo(Page.Page1)
case "/page2" => Msg.NavigateTo(Page.Page2)
case "/page3" => Msg.NavigateTo(Page.Page3)
case "/page4" => Msg.NavigateTo(Page.Page4)
case "/page5" => Msg.NavigateTo(Page.Page5)
case "/page6" => Msg.NavigateTo(Page.Page6)
case _ =>
println("Unknown route: " + loc.url)
Msg.NoOp

case loc: Location.External =>
Msg.NavigateToUrl(loc.href)

val hotReloadKey: String = "hotreload"

def init(flags: Map[String, String]): (Model, Cmd[IO, Msg]) =
Expand All @@ -29,10 +49,6 @@ object Sandbox extends TyrianApp[Msg, Model]:
case Right(model) => Msg.OverwriteModel(model)
},
Logger.info(flags.toString),
Navigation.getLocationHash {
case Navigation.Result.CurrentHash(hash) => Msg.NavigateTo(Page.fromString(hash))
case _ => Msg.NavigateTo(Page.Page1)
},
LocalStorage.key(0) {
case LocalStorage.Result.Key(key) => Msg.Log("Found local storage key: " + key)
case _ => Msg.Log("No local storage enties found.")
Expand All @@ -45,6 +61,9 @@ object Sandbox extends TyrianApp[Msg, Model]:
(Model.init, cmds)

def update(model: Model): Msg => (Model, Cmd[IO, Msg]) =
case Msg.NoOp =>
(model, Cmd.None)

case Msg.AddFruit =>
(model.copy(fruit = Fruit(model.fruitInput, false) :: model.fruit), Cmd.None)

Expand Down Expand Up @@ -187,11 +206,14 @@ object Sandbox extends TyrianApp[Msg, Model]:
(model.copy(tmpSaveData = content), Cmd.None)

case Msg.JumpToHomePage =>
(model, Navigation.setLocationHash(Page.Page1.toHash))
(model.copy(page = Page.Page1), Nav.pushUrl(Page.Page1.toUrlPath))

case Msg.NavigateTo(page) =>
(model.copy(page = page), Cmd.None)

case Msg.NavigateToUrl(href) =>
(model, Nav.loadUrl(href))

case Msg.TakeSnapshot =>
(model, HotReload.snapshot(hotReloadKey, model, Model.encode))

Expand Down Expand Up @@ -294,8 +316,19 @@ object Sandbox extends TyrianApp[Msg, Model]:
val navItems =
Page.values.toList.map { pg =>
if pg == model.page then li(style := CSS.`font-family`("sans-serif"))(pg.toNavLabel)
else li(style := CSS.`font-family`("sans-serif"))(a(href := pg.toHash)(pg.toNavLabel))
}
else
li(style := CSS.`font-family`("sans-serif")) {
a(href := pg.toUrlPath)(pg.toNavLabel)
}
} ++
List(
li(style := CSS.`font-family`("sans-serif")) {
a(href := "#foo" + Random.nextInt())("Random link")
},
li(style := CSS.`font-family`("sans-serif")) {
a(href := "https://tyrian.indigoengine.io/")("Tyrian's Website")
}
)

val counters = model.components.zipWithIndex.map { case (c, i) =>
Counter.view(c).map(msg => Msg.Modify(i, msg))
Expand Down Expand Up @@ -569,7 +602,6 @@ object Sandbox extends TyrianApp[Msg, Model]:

Sub.Batch(
webSocketSubs,
Navigation.onLocationHashChange(hashChange => Msg.NavigateTo(Page.fromString(hashChange.newFragment))),
simpleSubs,
clockSub,
Sub.animationFrameTick("frametick") { t =>
Expand All @@ -594,6 +626,7 @@ enum Msg:
case ClearStorage(key: String)
case DataLoaded(data: String)
case NavigateTo(page: Page)
case NavigateToUrl(href: String)
case JumpToHomePage
case OverwriteModel(model: Model)
case TakeSnapshot
Expand All @@ -617,6 +650,7 @@ enum Msg:
case AddFruit
case UpdateFruitInput(input: String)
case ToggleFruitAvailability(name: String)
case NoOp

enum Status:
case Connecting
Expand Down Expand Up @@ -685,29 +719,14 @@ enum Page:
case Page5 => "Http"
case Page6 => "Form"

def toHash: String =
def toUrlPath: String =
this match
case Page1 => "#page1"
case Page2 => "#page2"
case Page3 => "#page3"
case Page4 => "#page4"
case Page5 => "#page5"
case Page6 => "#page6"

object Page:
def fromString(pageString: String): Page =
pageString match
case "#page2" => Page2
case "page2" => Page2
case "#page3" => Page3
case "page3" => Page3
case "#page4" => Page4
case "page4" => Page4
case "#page5" => Page5
case "page5" => Page5
case "#page6" => Page6
case "page6" => Page6
case _ => Page1
case Page1 => "/page1"
case Page2 => "/page2"
case Page3 => "/page3"
case Page4 => "/page4"
case Page5 => "/page5"
case Page6 => "/page6"

object Model:
// val echoServer = "ws://ws.ifelse.io" // public echo server
Expand Down
3 changes: 0 additions & 3 deletions tyrian-io/src/main/scala/tyrian/TyrianApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,9 @@ package tyrian
import cats.effect.IO
import cats.effect.kernel.Resource
import cats.effect.unsafe.implicits.global
import org.scalajs.dom.document
import tyrian.TyrianAppF
import tyrian.runtime.TyrianRuntime

import scala.scalajs.js.annotation._

/** The TyrianApp trait can be extended to conveniently prompt you for all the methods needed for a Tyrian app, as well
* as providing a number of standard app launching methods.
*/
Expand Down
4 changes: 0 additions & 4 deletions tyrian-zio/src/main/scala/tyrian/TyrianApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@ package tyrian

import cats.effect.Async
import cats.effect.kernel.Resource
import cats.effect.unsafe.implicits.global
import org.scalajs.dom.document
import tyrian.TyrianAppF
import tyrian.runtime.TyrianRuntime
import zio.Runtime
import zio.Task
import zio.Unsafe

import scala.scalajs.js.annotation._

/** The TyrianApp trait can be extended to conveniently prompt you for all the methods needed for a Tyrian app, as well
* as providing a number of standard app launching methods.
*/
Expand Down
46 changes: 46 additions & 0 deletions tyrian/js/src/main/scala/tyrian/Location.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package tyrian

import cats.effect.kernel.Async
import cats.effect.kernel.Resource
import org.scalajs.dom.Element
import org.scalajs.dom.PopStateEvent
import org.scalajs.dom.document
import org.scalajs.dom.window

import scala.scalajs.js
import scala.scalajs.js.annotation._

sealed trait Location:
def locationDetails: LocationDetails
def isInternal: Boolean
def isExternal: Boolean
def href: String
def url: String

object Location:

final case class Internal(locationDetails: LocationDetails) extends Location:
export locationDetails.*
val isInternal: Boolean = true
val isExternal: Boolean = false

final case class External(locationDetails: LocationDetails) extends Location:
export locationDetails.*
val isInternal: Boolean = false
val isExternal: Boolean = true

/** Construct a Location from a given url, decides internal / external based on comparison with `currentLocation`
*/
def fromUrl(url: String, currentLocation: Location.Internal): Location =
val ld = LocationDetails.fromUrl(url)

if ld.protocol.isEmpty then Location.Internal(ld)
else if ld.origin == currentLocation.origin then Location.Internal(ld)
else Location.External(ld)

/** Location instances created from JS Location's are assumed to be internal links.
*/
def fromJsLocation(location: org.scalajs.dom.Location): Location.Internal =
Location.Internal(
LocationDetails.fromUrl(location.href)
)
Loading

0 comments on commit cf8772d

Please sign in to comment.