All Downloads are FREE. Search and download functionalities are using the official Maven repository.

kreuzberg.extras.SimpleRouter.scala Maven / Gradle / Ivy

The newest version!
package kreuzberg.extras

import kreuzberg.*
import kreuzberg.extras.Route.EagerRoute
import kreuzberg.extras.SimpleRouter.RoutingState
import kreuzberg.scalatags.*
import kreuzberg.scalatags.all.*

import scala.concurrent.Future
import scala.util.{Failure, Success}

/**
 * A Simple router implementation.
 * @param routes
 *   the different routes
 * @param notFoundRoute
 *   route to be called, if no route matches
 * @param titlePrefix
 *   prefix to be added for titles
 * @param errorHandler
 *   generic error handler
 * @param loadingHandler
 *   generic loading handler
 */
case class SimpleRouter(
    routes: Vector[Route],
    notFoundRoute: EagerRoute,
    titlePrefix: String = "",
    errorHandler: (UrlResource, Throwable) => Component = SimpleRouter.DefaultErrorHandler,
    loadingHandler: Option[Route] => Component = SimpleRouter.DefaultLoadingHandler
) extends SimpleComponentBase {

  override def assemble(using context: SimpleContext): Html = {
    val routingState = subscribe(SimpleRouter.routingStateModel)
    Logger.debug(s"Assembling SimpleRouter with value ${routingState} on model ${SimpleRouter.routingStateModel.id}")

    def handlePath(pushState: Boolean): EventSink[UrlResource] = {
      EventSink[UrlResource] { url =>
        val nextRoute = decideRoute(url)

        Logger.debug(s"Going to ${url} (route=${nextRoute}, pushState=${pushState})")
        val currentPath = BrowserRouting.getCurrentResource()
        val title       = nextRoute.preTitle(url)
        if (pushState && url != currentPath) {
          Logger.debug(s"Push state ${title}/${url}")
          BrowserRouting.pushState(title, url.str)
        }
        BrowserRouting.setDocumentTitle(titlePrefix + title)

        val initialState = decideInitialState(url, nextRoute)
        SimpleRouter.routingStateModel.set(initialState)

        initialState match {
          case loading: RoutingState.Loading =>
            val effect = loading.route.target(loading.url)
            effect.runAndHandle { result =>
              // Otherwise the user is probably on the nxt screen
              val stateAgain   = SimpleRouter.routingStateModel.read()
              val continueHere = stateAgain match {
                case l: RoutingState.Loading if l.invocation == loading.invocation => true
                case _                                                             =>
                  Logger.debug(
                    s"Discarding response of loading, not yet on the same loading page"
                  )
                  false
              }
              if (continueHere) {
                result.foreach { routingTarget =>
                  BrowserRouting.setDocumentTitle(routingTarget.title)
                }
                val translated = result match {
                  case Failure(exception) => RoutingState.Failed(loading.url, loading.route, exception)
                  case Success(v)         => RoutingState.Loaded(loading.url, loading.route, v.component)
                }
                SimpleRouter.routingStateModel.set(translated)
              }
            }
          case _                             => /* nothing */
        }
      }
    }

    val (component: Component, url: UrlResource) = routingState match {
      case RoutingState.Empty                         =>
        val url = BrowserRouting.getCurrentResource()
        add(
          EventSource.Assembled
            .map { _ => url }
            .to(handlePath(false))
        )
        (loadingHandler(None), url)
      case RoutingState.Loading(url, route, _)        =>
        (loadingHandler(Some(route)), url)
      case RoutingState.Failed(url, route, error)     =>
        (errorHandler(url, error), url)
      case RoutingState.Loaded(url, route, component) =>
        (component, url)
    }

    add(
      SimpleRouter.gotoChannel.to(handlePath(true))
    )

    add(
      SimpleRouter.reloadChannel.map(_ => url).to(handlePath(false))
    )

    add(
      EventSource.Js
        .window("popstate")
        .map(_ => BrowserRouting.getCurrentResource())
        .to(handlePath(false))
    )

    div(component.wrap)
  }

  private def decideRoute(url: UrlResource): Route = {
    routes.find(_.canHandle(url)).getOrElse(notFoundRoute)
  }

  private def decideInitialState(url: UrlResource, route: Route)(using AssemblerContext): RoutingState = {
    route match {
      case eager: EagerRoute =>
        val state     = eager.pathCodec.forceDecode(url)
        val component = eager.component(state)
        RoutingState.Loaded(url, route, component)
      case otherwise         =>
        RoutingState.Loading(url, route, Identifier.next())
    }
  }
}

object SimpleRouter {
  val gotoChannel: Channel[UrlResource] = Channel.create()

  sealed trait RoutingState

  object RoutingState {

    /** Not yet initialized */
    case object Empty extends RoutingState

    /**
     * Loading the next state.
     * @param invocation
     *   an id to correlate result to loading, otherwise we may overwrite forwarded states.
     */
    case class Loading(url: UrlResource, route: Route, invocation: Identifier) extends RoutingState

    /** Loaded state. */
    case class Loaded(url: UrlResource, route: Route, component: Component) extends RoutingState
    case class Failed(url: UrlResource, route: Route, error: Throwable)     extends RoutingState
  }

  private val routingStateModel = Model.create[RoutingState](RoutingState.Empty)

  /** Subscribable Loading State */
  def loading: Subscribeable[Boolean] = routingStateModel.map {
    case _: RoutingState.Loading => true
    case _                       => false
  }

  /** Subscribable current url (e.g. for Nav Buttons) */
  def currentUrl: Subscribeable[UrlResource] = routingStateModel.map {
    case RoutingState.Empty              => UrlResource()
    case RoutingState.Loading(url, _, _) => url
    case RoutingState.Loaded(url, _, _)  => url
    case RoutingState.Failed(url, _, _)  => url
  }

  /** Event Sink for going to a specific route. */
  def goto(url: UrlResource)(using hc: HandlerContext): Unit = gotoChannel(url)

  /** Force a reload. */
  val reloadChannel: Channel[Any] = Channel.create()
  def reload: EventSink[Any]      = EventSink { _ => reloadChannel() }

  /** Go to a specific fixed route */
  def gotoTarget(target: UrlResource)(using hc: HandlerContext): Unit = gotoChannel(target)

  /** Event sink for going to root (e.g. on logout) */
  def gotoRoot()(using hc: HandlerContext): Unit = gotoTarget(UrlResource("/"))

  case object EmptyComponent extends SimpleComponentBase {
    override def assemble(using c: SimpleContext): Html = {
      div("Loading...")
    }
  }

  val DefaultErrorHandler: (UrlResource, Throwable) => Component = { (url, error) =>
    new SimpleComponentBase {
      override def assemble(using c: SimpleContext): Html = {
        h2("Error")
        div(s"An unrecoverable error handled on loading route ${url}: ${error.getMessage}")
      }
    }
  }

  val DefaultLoadingHandler: Option[Route] => Component = route => {
    EmptyComponent
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy