First commit
This commit is contained in:
commit
dc1bb4ad31
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
target/
|
||||
.idea/
|
||||
.metals
|
||||
metals.sbt
|
||||
.vscode
|
||||
.bsp
|
||||
.bloop
|
1
.scalafmt.conf
Normal file
1
.scalafmt.conf
Normal file
@ -0,0 +1 @@
|
||||
version = "2.7.4"
|
23
README.md
Normal file
23
README.md
Normal file
@ -0,0 +1,23 @@
|
||||
Outwatch-Test
|
||||
====
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You should make sure that the following components are pre-installed on your machine:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
|
||||
## Create a module
|
||||
in sbt shell: `fastOptJS::webpack` or `fullOptJS::webpack`
|
||||
|
||||
## Working in dev mode
|
||||
In sbt shell, run `dev`. Then open `http://localhost:8080/` in your browser.
|
||||
|
||||
This sbt-task will start webpack dev server, compile your code each time it changes
|
||||
and auto-reload the page.
|
||||
webpack dev server will close automatically when you stop the `dev` task
|
||||
(e.g by hitting `Enter` in the sbt shell while you are in `dev` watch mode).
|
||||
|
||||
If you existed ungracefully and your webpack dev server is still open (check with `ps -aef | grep -v grep | grep webpack`),
|
||||
you can close it by running `fastOptJS::stopWebpackDevServer` in sbt.
|
25
assets/app/index.html
Normal file
25
assets/app/index.html
Normal file
@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Outwatch App</title>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" integrity="sha512-bLT0Qm9VnAYZDflyKcBaQ2gg0hSYNQrJ8RilYldYQ1FxQYoCLtUjuuRuZo+fjqhx/qtq/1itJ0C2ejDxltZVFg==" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/js/bootstrap.bundle.min.js" integrity="sha512-iceXjjbmB2rwoX93Ka6HAHP+B76IY1z0o3h+N1PeDtRSsyeetU3/0QKJqGyPJcX63zysNehggFwMC/bi7dvMig==" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/blk-design-system@1.0.2/assets/js/blk-design-system.min.js" integrity="sha256-0+0XIgwF6mAu4/UMvl0Eh9tvz2CLhNCo1taXQlpeyqg=" crossorigin="anonymous"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/css/bootstrap.min.css" integrity="sha512-oc9+XSs1H243/FRN9Rw62Fn8EtxjEYWHXRvjS43YtueEewbS6ObfXcJNyohjHqVKFPoXXUxwc+q1K7Dee6vv9g==" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/blk-design-system@1.0.2/assets/css/blk-design-system.min.css" integrity="sha256-6HRu9ZigEQ/feV+C1JxZyZSmMcqQ8Hymukmjdk4nl9A=" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/blk-design-system@1.0.2/assets/css/nucleo-icons.css" integrity="sha256-03+9B37/His+rzjhgA6Y1+ByU9DGN2ZPWjjA5CJJF2w=" crossorigin="anonymous">
|
||||
|
||||
<!-- <link rel="stylesheet" href="../css/demo.css"> -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="text/javascript" src="../outwatchapp-fastopt-library.js"></script>
|
||||
<script type="text/javascript" src="../outwatchapp-fastopt-loader.js"></script>
|
||||
<script type="text/javascript" src="../outwatchapp-fastopt.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
13
assets/index.html
Normal file
13
assets/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Outwatch-Test</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a href="app/#">Go To App</a>
|
||||
</body>
|
||||
|
||||
</html>
|
125
build.sbt
Normal file
125
build.sbt
Normal file
@ -0,0 +1,125 @@
|
||||
//organization := "wow.doge"
|
||||
//name := "Outwatchtest"
|
||||
//version := "0.1.0"
|
||||
|
||||
name := "OutwatchApp"
|
||||
|
||||
scalaVersion := "2.13.4"
|
||||
resolvers += "jitpack" at "https://jitpack.io"
|
||||
|
||||
libraryDependencies ++= Seq(
|
||||
"com.github.outwatch.outwatch" %%% "outwatch" % "61deece8",
|
||||
"com.github.outwatch.outwatch" %%% "outwatch-util" % "master-SNAPSHOT",
|
||||
"com.github.cornerman.colibri" %%% "colibri-monix" % "master-SNAPSHOT",
|
||||
"com.github.outwatch.outwatch" %%% "outwatch-monix" % "master-SNAPSHOT",
|
||||
"org.scalatest" %%% "scalatest" % "3.2.0" % Test,
|
||||
"org.typelevel" %%% "cats-core" % "2.1.1",
|
||||
"org.typelevel" %%% "cats-effect" % "2.1.4",
|
||||
"io.monix" %%% "monix" % "3.2.2",
|
||||
"io.monix" %%% "monix-bio" % "1.1.0",
|
||||
"io.circe" %%% "circe-core" % "0.13.0",
|
||||
"io.circe" %%% "circe-generic" % "0.13.0",
|
||||
"com.softwaremill.sttp.client" %%% "core" % "2.2.5",
|
||||
"com.softwaremill.sttp.client" %%% "monix" % "2.2.5",
|
||||
"com.softwaremill.sttp.client" %%% "circe" % "2.2.5",
|
||||
// "com.softwaremill.sttp.client" %%% "async-http-client-backend-monix" % "2.2.5",
|
||||
// "com.softwaremill.sttp.client3" %%% "httpclient-backend-monix" % "2.2.5",
|
||||
// "com.softwaremill.macwire" %%% "util" % "2.3.7",
|
||||
"com.softwaremill.macwire" %% "macros" % "2.3.7" % "provided",
|
||||
// "com.softwaremill.macwire" %%% "macrosakka" % "2.3.6" % "provided",
|
||||
"com.softwaremill.quicklens" %%% "quicklens" % "1.6.1",
|
||||
// "com.typesafe.scala-logging" %%% "scala-logging" % "3.9.2",
|
||||
// "io.circe" %%% "circe-config" % "0.8.0",
|
||||
"org.akka-js" %%% "shocon" % "1.0.0",
|
||||
"com.beachape" %%% "enumeratum-circe" % "1.6.1"
|
||||
// "com.clovellytech" %%% "outwatch-router" % "0.0.9+7-5be0b1a2+20201227-2019-SNAPSHOT"
|
||||
)
|
||||
|
||||
Compile / npmDependencies ++= Seq(
|
||||
// "jquery" -> "1.9.1",
|
||||
// "popper.js" -> "1.16.1",
|
||||
// // "@popperjs/core" -> "2.6.0",
|
||||
// "blk-design-system" -> "1.0.2",
|
||||
// "bootstrap" -> "4.5.3"
|
||||
"snabbdom" -> "git://github.com/outwatch/snabbdom.git#semver:0.7.5"
|
||||
)
|
||||
|
||||
// Compile / npmDevDependencies ++= Seq(
|
||||
// "css-loader" -> "5.0.1",
|
||||
// "style-loader" -> "2.0.0"
|
||||
// )
|
||||
|
||||
// stIgnore ++= List("jquery", "blk-design-system", "bootstrap")
|
||||
stIgnore ++= List("snabbdom")
|
||||
|
||||
enablePlugins(ScalaJSBundlerPlugin)
|
||||
enablePlugins(ScalablyTypedConverterPlugin)
|
||||
useYarn := true // makes scalajs-bundler use yarn instead of npm
|
||||
requireJsDomEnv in Test := true
|
||||
scalaJSUseMainModuleInitializer := true
|
||||
// configure Scala.js to emit a JavaScript module instead of a top-level script
|
||||
|
||||
scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule))
|
||||
scalacOptions ++=
|
||||
Seq(
|
||||
"-encoding",
|
||||
"UTF-8",
|
||||
"-deprecation",
|
||||
"-feature",
|
||||
"-language:existentials",
|
||||
"-language:experimental.macros",
|
||||
"-language:higherKinds",
|
||||
"-language:implicitConversions",
|
||||
"-unchecked",
|
||||
"-Xlint",
|
||||
"-Ywarn-numeric-widen",
|
||||
"-Ymacro-annotations",
|
||||
//silence warnings for by-name implicits
|
||||
"-Wconf:cat=lint-byname-implicit:s",
|
||||
//give errors on non exhaustive matches
|
||||
"-Wconf:msg=match may not be exhaustive:e",
|
||||
"-explaintypes" // Explain type errors in more detail.
|
||||
)
|
||||
|
||||
// hot reloading configuration:
|
||||
// https://github.com/scalacenter/scalajs-bundler/issues/180
|
||||
addCommandAlias(
|
||||
"dev",
|
||||
"; compile; fastOptJS::startWebpackDevServer; devwatch; fastOptJS::stopWebpackDevServer"
|
||||
)
|
||||
addCommandAlias("devwatch", "~; fastOptJS; copyFastOptJS")
|
||||
|
||||
version in webpack := "4.43.0"
|
||||
version in startWebpackDevServer := "3.11.0"
|
||||
webpackDevServerExtraArgs := Seq("--progress", "--color")
|
||||
webpackDevServerPort := 8080
|
||||
webpackConfigFile in fastOptJS := Some(
|
||||
baseDirectory.value / "webpack.config.dev.js"
|
||||
)
|
||||
// webpackConfigFile in fullOptJS := Some(
|
||||
// baseDirectory.value / "webpack.config.js"
|
||||
// )
|
||||
|
||||
// https://scalacenter.github.io/scalajs-bundler/cookbook.html#performance
|
||||
webpackBundlingMode in fastOptJS := BundlingMode.LibraryOnly()
|
||||
|
||||
// when running the "dev" alias, after every fastOptJS compile all artifacts are copied into
|
||||
// a folder which is served and watched by the webpack devserver.
|
||||
// this is a workaround for: https://github.com/scalacenter/scalajs-bundler/issues/180
|
||||
lazy val copyFastOptJS =
|
||||
TaskKey[Unit]("copyFastOptJS", "Copy javascript files to target directory")
|
||||
copyFastOptJS := {
|
||||
val inDir = (crossTarget in (Compile, fastOptJS)).value
|
||||
val outDir = (crossTarget in (Compile, fastOptJS)).value / "dev"
|
||||
val files = Seq(
|
||||
name.value.toLowerCase + "-fastopt-loader.js",
|
||||
name.value.toLowerCase + "-fastopt.js",
|
||||
name.value.toLowerCase + "-fastopt.js.map"
|
||||
) map { p => (inDir / p, outDir / p) }
|
||||
IO.copy(
|
||||
files,
|
||||
overwrite = true,
|
||||
preserveLastModified = true,
|
||||
preserveExecutable = true
|
||||
)
|
||||
}
|
1
project/build.properties
Normal file
1
project/build.properties
Normal file
@ -0,0 +1 @@
|
||||
sbt.version=1.4.6
|
4
project/plugins.sbt
Normal file
4
project/plugins.sbt
Normal file
@ -0,0 +1,4 @@
|
||||
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.3.0")
|
||||
addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.20.0")
|
||||
resolvers += Resolver.bintrayRepo("oyvindberg", "converter")
|
||||
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta29.1")
|
145
src/main/scala/outwatch/router/Router.scala
Normal file
145
src/main/scala/outwatch/router/Router.scala
Normal file
@ -0,0 +1,145 @@
|
||||
package outwatch.router
|
||||
|
||||
import org.scalajs.dom.window
|
||||
import colibri.Observable
|
||||
import outwatch.util.Store
|
||||
import outwatch.util.Reducer
|
||||
import cats.effect.Sync
|
||||
import outwatch._
|
||||
import outwatch.dsl._
|
||||
import outwatch.router.dsl.Path
|
||||
import outwatch.router.dsl.HashPath
|
||||
import outwatch.router.dsl.Root
|
||||
|
||||
sealed trait Action
|
||||
final case class Replace(path: Path) extends Action
|
||||
final case class HistoryEvent(path: Path) extends Action
|
||||
|
||||
final case class RouterState[P](page: P)
|
||||
|
||||
/** An AppRouter handles parsing of URLs and mapping to pages of the given type.
|
||||
* @param siteRoot - The prefix part of a pathname, or the subpath at which your site is applied.
|
||||
* Usually this is just Root, but your site might need a prefix as in /my_site/[parsed pathname]
|
||||
* @param parent - The parent path at which this router is mounted. You can have routers contained in subroots of your site.
|
||||
* @param f - a mapping function from a Path to a page P.
|
||||
* @tparam F - the effect type
|
||||
* @tparam P - Your page type, such as a sealed trait root type.
|
||||
*/
|
||||
class AppRouter[F[_]: Sync, P](siteRoot: Path, parent: Path, f: Path => P) {
|
||||
// Sync from the required page to the window.location
|
||||
def routerReducer(state: RouterState[P], action: Action): RouterState[P] =
|
||||
action match {
|
||||
case Replace(path) =>
|
||||
println(s"Path = $path")
|
||||
window.history.pushState(
|
||||
"",
|
||||
"",
|
||||
HashPath(siteRoot, HashPath(parent, path)).toUrlString
|
||||
)
|
||||
state.copy(page = f(path))
|
||||
case HistoryEvent(path) =>
|
||||
println(s"Path = $path")
|
||||
state.copy(page = f(path))
|
||||
case _ => state
|
||||
}
|
||||
|
||||
def store: F[RouterStore[P]] = {
|
||||
val startingPath = HashPath(window.location.hash.substring(1))
|
||||
|
||||
Store.create[F, Action, RouterState[P]](
|
||||
Replace(startingPath),
|
||||
RouterState(f(startingPath)),
|
||||
Reducer(routerReducer _)
|
||||
)
|
||||
}
|
||||
|
||||
def link(
|
||||
linkHref: String
|
||||
)(attrs: VDomModifier*)(implicit store: RouterStore[P]): BasicVNode =
|
||||
a(href := linkHref)(
|
||||
onClick.preventDefault.useLazy(
|
||||
Replace(HashPath(linkHref))
|
||||
) --> store.sink,
|
||||
attrs
|
||||
)
|
||||
|
||||
def render(resolver: RouterResolve[P])(implicit
|
||||
store: RouterStore[P]
|
||||
): Observable[VDomModifier] =
|
||||
store.map { case (_, RouterState(p)) => resolver(p) }
|
||||
|
||||
def watch()(implicit store: RouterStore[P]) =
|
||||
emitter(outwatch.dsl.events.window.onPopState).useLazy(
|
||||
HistoryEvent(HashPath(org.scalajs.dom.window.location.hash.substring(1)))
|
||||
) --> store.sink
|
||||
|
||||
// def link(
|
||||
// p: P
|
||||
// )(attrs: VDomModifier*)(implicit store: RouterStore[P]): BasicVNode =
|
||||
// a(href := p.toString())(
|
||||
// onClick.preventDefault.useLazy(
|
||||
// Replace(Path(p.toString()))
|
||||
// ) --> store.sink,
|
||||
// attrs
|
||||
// )
|
||||
}
|
||||
|
||||
object AppRouter {
|
||||
def render[P](resolver: RouterResolve[P])(implicit
|
||||
store: RouterStore[P]
|
||||
): Observable[VDomModifier] =
|
||||
store.map { case (_, RouterState(p)) => resolver(p) }
|
||||
|
||||
def watch[P]()(implicit store: RouterStore[P]) =
|
||||
emitter(
|
||||
outwatch.dsl.events.window.onPopState
|
||||
// .doOnNext(e =>
|
||||
// println(
|
||||
// s"changed ${org.scalajs.dom.window.location.toString()}"
|
||||
// )
|
||||
// )
|
||||
).useLazy(
|
||||
HistoryEvent(Path(org.scalajs.dom.window.location.pathname))
|
||||
) --> store.sink
|
||||
|
||||
def create[F[_]: Sync, P](notFound: P)(
|
||||
f: PartialFunction[Path, P]
|
||||
): AppRouter[F, P] =
|
||||
create[F, P](Root, notFound)(f)
|
||||
|
||||
def create[F[_]: Sync, P](parent: Path, notFound: P)(
|
||||
f: PartialFunction[Path, P]
|
||||
): AppRouter[F, P] =
|
||||
new AppRouter[F, P](Root, parent, f.lift.andThen(_.getOrElse(notFound)))
|
||||
|
||||
def createParseSiteRoot[F[_]: Sync, P](notFound: P)(
|
||||
f: PartialFunction[Path, P]
|
||||
): AppRouter[F, P] =
|
||||
createParseSiteRoot[F, P](Root, notFound)(f)
|
||||
|
||||
/** Automatically determine what siteroot we're using, based on the current URL and expected parent.
|
||||
* For example, your site could be deployed at /example/directory/, your router root path could be /names,
|
||||
* and the current url could be /example/directory/names/alice. So given the call:
|
||||
* createParseSubRoot[Page](Path("/names"), NotFound)(f), the router will work out that the window location
|
||||
* prefix must be /example/directory/names, and will handle actions such as Replace(Path("/names/bob"))
|
||||
* @param parent the parent for this router, another path perhaps managed by another router
|
||||
* @param notFound - the default case page assignment
|
||||
* @param f - a router function from Path to instances of your page type
|
||||
* @tparam P - your page type
|
||||
*/
|
||||
def createParseSiteRoot[F[_]: Sync, P](parent: Path, notFound: P)(
|
||||
f: PartialFunction[Path, P]
|
||||
): AppRouter[F, P] = {
|
||||
val initUrl = window.location.pathname
|
||||
// url is of form /sra/srb/src/pa/pb/pc...
|
||||
// so just drop the parent part from the right of the url if it exists.
|
||||
|
||||
val siteRoot = initUrl.lastIndexOf(parent.toString) match {
|
||||
case x if x < 1 => Root
|
||||
case x => Path(initUrl.substring(0, x))
|
||||
}
|
||||
|
||||
val routerFun: Path => P = f.lift.andThen(_.getOrElse(notFound))
|
||||
new AppRouter[F, P](siteRoot, parent, routerFun)
|
||||
}
|
||||
}
|
10
src/main/scala/outwatch/router/dsl/Extractors.scala
Normal file
10
src/main/scala/outwatch/router/dsl/Extractors.scala
Normal file
@ -0,0 +1,10 @@
|
||||
package outwatch.router.dsl
|
||||
|
||||
trait Extractors {
|
||||
object IntVar {
|
||||
def unapply(str: String): Option[Int] = {
|
||||
if (!str.isEmpty) str.toIntOption
|
||||
else None
|
||||
}
|
||||
}
|
||||
}
|
294
src/main/scala/outwatch/router/dsl/Path.scala
Normal file
294
src/main/scala/outwatch/router/dsl/Path.scala
Normal file
@ -0,0 +1,294 @@
|
||||
// Completely copied from org.http4s.dsl.impl
|
||||
|
||||
package outwatch.router.dsl
|
||||
|
||||
import cats.implicits._
|
||||
import java.nio.{ByteBuffer, CharBuffer}
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets.UTF_8
|
||||
|
||||
/** Base class for path extractors. */
|
||||
trait Path {
|
||||
def /(child: String): Path = new /(this, child)
|
||||
def toList: List[String]
|
||||
def parent: Path
|
||||
def lastOption: Option[String]
|
||||
def startsWith(other: Path): Boolean
|
||||
def pathString: String
|
||||
def toUrlString: String = if (pathString.isEmpty) "" else pathString
|
||||
}
|
||||
|
||||
trait HashPath extends Path {
|
||||
override def toUrlString: String = if (pathString.isEmpty) "#" else pathString
|
||||
}
|
||||
|
||||
object HashPath {
|
||||
def apply(str: String): Path =
|
||||
if (str == "" || str == "/" || str == "#")
|
||||
Root
|
||||
else {
|
||||
val segments = str.split("/", -1)
|
||||
// .head is safe because split always returns non-empty array
|
||||
val segments0 = if (segments.head == "") segments.drop(1) else segments
|
||||
segments0.foldLeft(Root: Path)((path, seg) =>
|
||||
path / UrlCodingUtils.urlDecode(seg)
|
||||
)
|
||||
}
|
||||
|
||||
def apply(first: String, rest: String*): Path =
|
||||
rest.foldLeft(Root / first)(_ / _)
|
||||
|
||||
def apply(list: List[String]): Path =
|
||||
list.foldLeft(Root: Path)(_ / _)
|
||||
|
||||
def apply(left: Path, right: Path): Path =
|
||||
right.toList.foldLeft(left)(_ / _)
|
||||
|
||||
def unapplySeq(path: Path): Some[List[String]] =
|
||||
Some(path.toList)
|
||||
}
|
||||
|
||||
object Path {
|
||||
|
||||
/** Constructs a path from a single string by splitting on the `'/'`
|
||||
* character.
|
||||
*
|
||||
* Leading slashes do not create an empty path segment. This is to
|
||||
* reflect that there is no distinction between a request to
|
||||
* `http://www.example.com` from `http://www.example.com/`.
|
||||
*
|
||||
* Trailing slashes result in a path with an empty final segment,
|
||||
* unless the path is `"/"`, which is `Root`.
|
||||
*
|
||||
* Segments are URL decoded.
|
||||
*
|
||||
* {{{
|
||||
* scala> Path("").toList
|
||||
* res0: List[String] = List()
|
||||
* scala> Path("/").toList
|
||||
* res1: List[String] = List()
|
||||
* scala> Path("a").toList
|
||||
* res2: List[String] = List(a)
|
||||
* scala> Path("/a").toList
|
||||
* res3: List[String] = List(a)
|
||||
* scala> Path("/a/").toList
|
||||
* res4: List[String] = List(a, "")
|
||||
* scala> Path("//a").toList
|
||||
* res5: List[String] = List("", a)
|
||||
* scala> Path("/%2F").toList
|
||||
* res0: List[String] = List(/)
|
||||
* }}}
|
||||
*/
|
||||
def apply(str: String): Path =
|
||||
if (str == "" || str == "/")
|
||||
Root
|
||||
else {
|
||||
val segments = str.split("/", -1)
|
||||
// .head is safe because split always returns non-empty array
|
||||
val segments0 = if (segments.head == "") segments.drop(1) else segments
|
||||
segments0.foldLeft(Root: Path)((path, seg) =>
|
||||
path / UrlCodingUtils.urlDecode(seg)
|
||||
)
|
||||
}
|
||||
|
||||
def apply(first: String, rest: String*): Path =
|
||||
rest.foldLeft(Root / first)(_ / _)
|
||||
|
||||
def apply(list: List[String]): Path =
|
||||
list.foldLeft(Root: Path)(_ / _)
|
||||
|
||||
def apply(left: Path, right: Path): Path =
|
||||
right.toList.foldLeft(left)(_ / _)
|
||||
|
||||
def unapplySeq(path: Path): Some[List[String]] =
|
||||
Some(path.toList)
|
||||
//
|
||||
// def unapplySeq[F[_]](request: Request[F]): Some[List[String]] =
|
||||
// Some(Path(request.pathInfo).toList)
|
||||
}
|
||||
|
||||
final case class /(parent: Path, child: String) extends Path {
|
||||
lazy val toList: List[String] = parent.toList ++ List(child)
|
||||
|
||||
def lastOption: Some[String] = Some(child)
|
||||
|
||||
lazy val asString: String =
|
||||
s"${parent.pathString}/${UrlCodingUtils.pathEncode(child)}"
|
||||
|
||||
override def toString: String = asString
|
||||
|
||||
def pathString: String = asString
|
||||
|
||||
def startsWith(other: Path): Boolean = {
|
||||
val components = other.toList
|
||||
toList.take(components.length) === components
|
||||
}
|
||||
}
|
||||
|
||||
final case class Blank(parent: Path, child: String) extends Path {
|
||||
lazy val toList: List[String] = parent.toList ++ List(child)
|
||||
|
||||
def lastOption: Some[String] = Some(child)
|
||||
|
||||
lazy val asString: String =
|
||||
s"${parent.pathString}${UrlCodingUtils.pathEncode(child)}"
|
||||
|
||||
override def toString: String = asString
|
||||
|
||||
def pathString: String = asString
|
||||
|
||||
def startsWith(other: Path): Boolean = {
|
||||
val components = other.toList
|
||||
toList.take(components.length) === components
|
||||
}
|
||||
}
|
||||
|
||||
/** Path separator extractor:
|
||||
* {{{
|
||||
* Path("/1/2/3/test.json") match {
|
||||
* case "1" /: "2" /: _ => ...
|
||||
* }}}
|
||||
*/
|
||||
object /: {
|
||||
def unapply(path: Path): Option[(String, Path)] =
|
||||
path.toList match {
|
||||
case head :: tail => Some(head -> Path(tail))
|
||||
case Nil => None
|
||||
}
|
||||
}
|
||||
|
||||
/** Root extractor:
|
||||
* {{{
|
||||
* Path("/") match {
|
||||
* case Root => ...
|
||||
* }
|
||||
* }}}
|
||||
*/
|
||||
case object Root extends Path {
|
||||
// override def /(child: String): Path = new Blank(this, child)
|
||||
def toList: List[String] = Nil
|
||||
def parent: Path = this
|
||||
def lastOption: None.type = None
|
||||
override def toString = "Root"
|
||||
def pathString: String = "#"
|
||||
def startsWith(other: Path): Boolean = other == Root
|
||||
}
|
||||
|
||||
private[router] object UrlCodingUtils {
|
||||
|
||||
private val lower = ('a' to 'z').toSet
|
||||
private val upper = ('A' to 'Z').toSet
|
||||
private val num = ('0' to '9').toSet
|
||||
val Unreserved: Set[Char] = lower ++ upper ++ num ++ "-_.~"
|
||||
|
||||
private val toSkip: Set[Char] = Unreserved ++ "!$&'()*+,;=:/?@"
|
||||
|
||||
private val HexUpperCaseChars: Array[Char] = ('A' to 'F').toArray
|
||||
|
||||
/** Percent-encodes a string. Depending on the parameters, this method is
|
||||
* appropriate for URI or URL form encoding. Any resulting percent-encodings
|
||||
* are normalized to uppercase.
|
||||
*
|
||||
* @param toEncode the string to encode
|
||||
* @param charset the charset to use for characters that are percent encoded
|
||||
* @param spaceIsPlus if space is not skipped, determines whether it will be
|
||||
* rendreed as a `"+"` or a percent-encoding according to `charset`.
|
||||
* @param toSkip a predicate of characters exempt from encoding. In typical
|
||||
* use, this is composed of all Unreserved URI characters and sometimes a
|
||||
* subset of Reserved URI characters.
|
||||
*/
|
||||
def urlEncode(
|
||||
toEncode: String,
|
||||
charset: Charset = UTF_8,
|
||||
spaceIsPlus: Boolean = false,
|
||||
toSkip: Char => Boolean = toSkip
|
||||
): String = {
|
||||
val in = charset.encode(toEncode)
|
||||
val out = CharBuffer.allocate((in.remaining() * 3).toInt)
|
||||
while (in.hasRemaining) {
|
||||
val c = in.get().toChar
|
||||
if (toSkip(c)) {
|
||||
out.put(c)
|
||||
} else if (c == ' ' && spaceIsPlus) {
|
||||
out.put('+')
|
||||
} else {
|
||||
out.put('%')
|
||||
out.put(HexUpperCaseChars((c >> 4) & 0xf))
|
||||
out.put(HexUpperCaseChars(c & 0xf))
|
||||
}
|
||||
}
|
||||
out.flip()
|
||||
out.toString
|
||||
}
|
||||
|
||||
private val SkipEncodeInPath =
|
||||
Unreserved ++ ":@!$&'()*+,;="
|
||||
|
||||
def pathEncode(s: String, charset: Charset = UTF_8): String =
|
||||
UrlCodingUtils.urlEncode(s, charset, false, SkipEncodeInPath)
|
||||
|
||||
/** Percent-decodes a string.
|
||||
*
|
||||
* @param toDecode the string to decode
|
||||
* @param charset the charset of percent-encoded characters
|
||||
* @param plusIsSpace true if `'+'` is to be interpreted as a `' '`
|
||||
* @param toSkip a predicate of characters whose percent-encoded form
|
||||
* is left percent-encoded. Almost certainly should be left empty.
|
||||
*/
|
||||
def urlDecode(
|
||||
toDecode: String,
|
||||
charset: Charset = UTF_8,
|
||||
plusIsSpace: Boolean = false,
|
||||
toSkip: Char => Boolean = Function.const(false)
|
||||
): String = {
|
||||
val in = CharBuffer.wrap(toDecode)
|
||||
// reserve enough space for 3-byte UTF-8 characters. 4-byte characters are represented
|
||||
// as surrogate pairs of characters, and will get a luxurious 6 bytes of space.
|
||||
val out = ByteBuffer.allocate(in.remaining() * 3)
|
||||
while (in.hasRemaining) {
|
||||
val mark = in.position()
|
||||
val c = in.get()
|
||||
if (c == '%') {
|
||||
if (in.remaining() >= 2) {
|
||||
val xc = in.get()
|
||||
val yc = in.get()
|
||||
// scalastyle:off magic.number
|
||||
val x = Character.digit(xc, 0x10)
|
||||
val y = Character.digit(yc, 0x10)
|
||||
// scalastyle:on magic.number
|
||||
if (x != -1 && y != -1) {
|
||||
val oo = (x << 4) + y
|
||||
if (!toSkip(oo.toChar)) {
|
||||
out.put(oo.toByte)
|
||||
} else {
|
||||
out.put('%'.toByte)
|
||||
out.put(xc.toByte)
|
||||
out.put(yc.toByte)
|
||||
}
|
||||
} else {
|
||||
out.put('%'.toByte)
|
||||
in.position(mark + 1)
|
||||
}
|
||||
} else {
|
||||
// This is an invalid encoding. Fail gracefully by treating the '%' as
|
||||
// a literal.
|
||||
out.put(c.toByte)
|
||||
while (in.hasRemaining) out.put(in.get().toByte)
|
||||
}
|
||||
} else if (c == '+' && plusIsSpace) {
|
||||
out.put(' '.toByte)
|
||||
} else {
|
||||
// normally `out.put(c.toByte)` would be enough since the url is %-encoded,
|
||||
// however there are cases where a string can be partially decoded
|
||||
// so we have to make sure the non us-ascii chars get preserved properly.
|
||||
if (this.toSkip(c)) {
|
||||
out.put(c.toByte)
|
||||
} else {
|
||||
out.put(charset.encode(String.valueOf(c)))
|
||||
}
|
||||
}
|
||||
}
|
||||
out.flip()
|
||||
charset.decode(out).toString
|
||||
}
|
||||
}
|
5
src/main/scala/outwatch/router/dsl/package.scala
Normal file
5
src/main/scala/outwatch/router/dsl/package.scala
Normal file
@ -0,0 +1,5 @@
|
||||
package outwatch.router
|
||||
|
||||
// import outwatch.router.dsl.Extractors
|
||||
|
||||
package object dsl extends Extractors
|
10
src/main/scala/outwatch/router/package.scala
Normal file
10
src/main/scala/outwatch/router/package.scala
Normal file
@ -0,0 +1,10 @@
|
||||
package outwatch
|
||||
|
||||
import outwatch._
|
||||
import colibri.ProSubject
|
||||
|
||||
package object router {
|
||||
type RouterStore[P] = ProSubject[Action, (Action, RouterState[P])]
|
||||
|
||||
type RouterResolve[P] = PartialFunction[P, VDomModifier]
|
||||
}
|
48
src/main/scala/outwatch/util/MonixWebSocket.scala
Normal file
48
src/main/scala/outwatch/util/MonixWebSocket.scala
Normal file
@ -0,0 +1,48 @@
|
||||
package outwatch.util
|
||||
|
||||
import outwatch.helpers._
|
||||
// import colibri._
|
||||
import org.scalajs.dom.{Event, MessageEvent}
|
||||
import cats.effect.Sync
|
||||
import monix.reactive.Observable
|
||||
import monix.reactive.Observer
|
||||
import monix.execution.Cancelable
|
||||
import monix.reactive.OverflowStrategy
|
||||
import scala.concurrent.Future
|
||||
import monix.execution.Ack
|
||||
import cats.Show
|
||||
|
||||
object MonixWS {
|
||||
// implicit def toObserver[F[_]: Sync, S: Show](
|
||||
// socket: MonixWS[F, S]
|
||||
// ): F[Observer[S]] = socket.sink
|
||||
// implicit def toObservable[F[_]: Sync, S: Show](
|
||||
// socket: MonixWS[F, S]
|
||||
// ): Observable[MessageEvent] = socket.source
|
||||
}
|
||||
|
||||
class MonixWS[F[_], S](val url: String)(implicit F: Sync[F], S: Show[S]) {
|
||||
val ws = new org.scalajs.dom.WebSocket(url)
|
||||
|
||||
lazy val source: Observable[MessageEvent] =
|
||||
Observable.create[MessageEvent](OverflowStrategy.Unbounded) { sub =>
|
||||
ws.onmessage = (e: MessageEvent) => sub.onNext(e)
|
||||
ws.onerror =
|
||||
(e: Event) => sub.onError(new Exception(s"Error in WebSocket: $e"))
|
||||
Cancelable(() => ws.close())
|
||||
}
|
||||
|
||||
lazy val sink: F[Observer[S]] = {
|
||||
F.delay {
|
||||
new Observer[S] {
|
||||
override def onNext(elem: S): Future[Ack] = {
|
||||
ws.send(S.show(elem))
|
||||
Future.successful(Ack.Continue)
|
||||
}
|
||||
override def onError(ex: Throwable): Unit =
|
||||
OutwatchTracing.errorSubject.onNext(ex)
|
||||
override def onComplete(): Unit = ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
39
src/main/scala/outwatch/util/WebSocket2.scala
Normal file
39
src/main/scala/outwatch/util/WebSocket2.scala
Normal file
@ -0,0 +1,39 @@
|
||||
package outwatch.util
|
||||
|
||||
import outwatch.helpers._
|
||||
import colibri._
|
||||
import org.scalajs.dom.{Event, MessageEvent}
|
||||
import cats.effect.Sync
|
||||
|
||||
object WebSocketF {
|
||||
implicit def toObserver[F[_]: Sync](
|
||||
socket: WebSocketF[F]
|
||||
): F[Observer[String]] =
|
||||
socket.sink
|
||||
implicit def toObservable[F[_]: Sync](
|
||||
socket: WebSocketF[F]
|
||||
): Observable[MessageEvent] =
|
||||
socket.source
|
||||
}
|
||||
|
||||
class WebSocketF[F[_]](val url: String)(implicit F: Sync[F]) {
|
||||
val ws = new org.scalajs.dom.WebSocket(url)
|
||||
|
||||
lazy val source: Observable[MessageEvent] =
|
||||
Observable.create[MessageEvent] { observer =>
|
||||
ws.onmessage = (e: MessageEvent) => observer.onNext(e)
|
||||
ws.onerror =
|
||||
(e: Event) => observer.onError(new Exception(s"Error in WebSocket: $e"))
|
||||
Cancelable(() => ws.close())
|
||||
}
|
||||
|
||||
lazy val sink: F[Observer[String]] = {
|
||||
F.delay {
|
||||
new Observer[String] {
|
||||
override def onNext(elem: String): Unit = ws.send(elem)
|
||||
override def onError(ex: Throwable): Unit =
|
||||
OutwatchTracing.errorSubject.onNext(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
19
src/main/scala/outwatchapp/IOUtils.scala
Normal file
19
src/main/scala/outwatchapp/IOUtils.scala
Normal file
@ -0,0 +1,19 @@
|
||||
import cats.arrow.FunctionK
|
||||
import monix.bio.IO
|
||||
|
||||
object IOUtils {
|
||||
def toIO[T](task: monix.eval.Task[T]) =
|
||||
IO.deferAction(implicit s => IO.from(task))
|
||||
|
||||
def toTask[T](bio: monix.bio.IO[Throwable, T]) =
|
||||
monix.eval.Task.deferAction(implicit s => bio.to[monix.eval.Task])
|
||||
|
||||
val ioTaskMapk =
|
||||
new FunctionK[monix.eval.Task, monix.bio.Task] {
|
||||
|
||||
override def apply[A](
|
||||
fa: monix.eval.Task[A]
|
||||
): monix.bio.Task[A] = toIO(fa)
|
||||
|
||||
}
|
||||
}
|
291
src/main/scala/outwatchapp/OutwatchApp.scala
Normal file
291
src/main/scala/outwatchapp/OutwatchApp.scala
Normal file
@ -0,0 +1,291 @@
|
||||
package outwatchapp
|
||||
|
||||
import outwatch._
|
||||
import outwatch.dsl._
|
||||
|
||||
import cats.effect.ExitCode
|
||||
import monix.bio._
|
||||
import colibri.ext.monix._
|
||||
import monix.reactive.Observable
|
||||
import scala.concurrent.duration._
|
||||
import cats.implicits._
|
||||
import outwatch.router.AppRouter
|
||||
import outwatch.router.dsl._
|
||||
import sttp.client.impl.monix.FetchMonixBackend
|
||||
import outwatch.reactive.handlers.monix._
|
||||
import sttp.client._
|
||||
import monix.{eval => me}
|
||||
import nova.monadic_sfx.ui.components.todo.TodoListStore
|
||||
import outwatchapp.implicits._
|
||||
import monix.eval.Coeval
|
||||
import nova.monadic_sfx.ui.components.todo.Todo
|
||||
|
||||
object OutwatchApp extends BIOApp {
|
||||
val counter2 = Observable.interval(1.second)
|
||||
val counter = div(
|
||||
"Hmm",
|
||||
button(
|
||||
onClick
|
||||
.useScan(0)(_ + 1)
|
||||
.handled(source => VDomModifier("Counter: ", source))
|
||||
)
|
||||
)
|
||||
|
||||
implicit val backend = FetchMonixBackend()
|
||||
|
||||
val handlerTask = Handler.createF[Task, String]("empty")
|
||||
|
||||
def request(query: String) = basicRequest
|
||||
.get(
|
||||
uri"https://jsonplaceholder.typicode.com/todos/${query.toIntOption.getOrElse(0)}"
|
||||
)
|
||||
.send()
|
||||
.flatMap {
|
||||
_.body match {
|
||||
case Right(value) =>
|
||||
me.Task(println(value)) >>
|
||||
me.Task(value)
|
||||
case Left(error) =>
|
||||
me.Task(println(error)) >>
|
||||
me.Task(error)
|
||||
|
||||
}
|
||||
}
|
||||
val component = Task.deferAction(implicit s =>
|
||||
for {
|
||||
handler <- handlerTask
|
||||
todoContent <- Handler.createF[Task, String]
|
||||
todoStore <- TodoListStore()
|
||||
res <- Task(
|
||||
div(
|
||||
// cls := "col-lg-8 bd-example",
|
||||
div(cls := "alert alert-danger", "Some Error Occured!"),
|
||||
div(
|
||||
form(
|
||||
h4(cls := "form-title", "User Finder"),
|
||||
div(
|
||||
cls := "form-group",
|
||||
label(
|
||||
color := "hsla(0,0%,100%,0.8)",
|
||||
forId := "httpInput",
|
||||
"Enter an id: "
|
||||
),
|
||||
input(
|
||||
idAttr := "httpInput",
|
||||
typ := "text",
|
||||
cls := "form-control",
|
||||
placeholder := "0",
|
||||
onInput.value --> handler
|
||||
),
|
||||
label(
|
||||
color := "hsla(0,0%,100%,0.8)",
|
||||
"Enter content for todo"
|
||||
),
|
||||
small(cls := "form-text text-muted", "default is 0")
|
||||
),
|
||||
div(
|
||||
cls := "form-group",
|
||||
input(
|
||||
cls := "form-control",
|
||||
onInput.value --> todoContent
|
||||
),
|
||||
button(
|
||||
cls := "btn",
|
||||
cls := "btn-info form-control",
|
||||
"Add Todo",
|
||||
onClick.preventDefault(
|
||||
todoContent.map(TodoListStore.Add)
|
||||
) --> todoStore.sink
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
div(
|
||||
p(
|
||||
cls := "profile-description",
|
||||
handler
|
||||
.doOnNext(str => me.Task(println(str)))
|
||||
.mapEval(request)
|
||||
.map(div(_)),
|
||||
div(
|
||||
"Todos: ",
|
||||
todoStore.doOnNextF { case (a, s) => Coeval(println(s)) }.map {
|
||||
case (a, s) => div(renderTodos(s.todos))
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
} yield res
|
||||
)
|
||||
|
||||
def renderTodos(todos: Seq[Todo]) = div(
|
||||
ul(
|
||||
todos.map(todo => li(div(s"id: ${todo.id} content: ${todo.content}")))
|
||||
)
|
||||
)
|
||||
|
||||
sealed trait Page
|
||||
case object Home extends Page
|
||||
case object SomePage extends Page
|
||||
case class UserHome(id: Int) extends Page
|
||||
case object NotFound extends Page
|
||||
|
||||
val router = AppRouter.create[Task, Page](NotFound) {
|
||||
case Root => Home
|
||||
case Root / "user" / IntVar(id) => UserHome(id)
|
||||
case Root / "some-page" => SomePage
|
||||
}
|
||||
val counterComponent =
|
||||
Task.deferAction(implicit s =>
|
||||
Task(div(p(cls := "profile-description", "count: ", counter2)))
|
||||
)
|
||||
|
||||
def resolver: PartialFunction[Page, VDomModifier] = {
|
||||
case Home =>
|
||||
div(
|
||||
div(cls := "title", "Home"),
|
||||
div(
|
||||
cls := "card",
|
||||
div(
|
||||
cls := "card-body",
|
||||
counterComponent,
|
||||
p(
|
||||
cls := "profile-description",
|
||||
div(
|
||||
"hm",
|
||||
htmlTag("blockQuote")(
|
||||
cls := "blockquote",
|
||||
p(
|
||||
cls := "mb-0",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante."
|
||||
),
|
||||
footer(
|
||||
cls := "blockquote-footer",
|
||||
"Someone famous in ",
|
||||
cite(title := "Source Title", "Source Title")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
case SomePage =>
|
||||
div(div(cls := "title", "SomePage"), component, div(cls := "slider"))
|
||||
case UserHome(id) => div(div(cls := "title", "UserHome"), s"User id: $id")
|
||||
case NotFound =>
|
||||
div(
|
||||
div(cls := "title", "NotFound"),
|
||||
p(cls := "profile-description", "notfound")
|
||||
)
|
||||
}
|
||||
|
||||
def run(args: List[String]): UIO[ExitCode] = {
|
||||
// div(h1("Hello World"), counterComponent, Task(1))
|
||||
import org.scalajs.dom.document
|
||||
val el =
|
||||
document.createElement("div")
|
||||
el.setAttribute("id", "#app")
|
||||
document.body.appendChild(el)
|
||||
|
||||
router.store
|
||||
.flatMap(implicit store =>
|
||||
OutWatch
|
||||
.renderInto(
|
||||
el,
|
||||
div(
|
||||
htmlTag("nav")(
|
||||
cls := "navbar navbar-expand-lg bg-primary ",
|
||||
attr("color-on-scroll") := "100",
|
||||
div(
|
||||
cls := "container",
|
||||
div(
|
||||
cls := "navbar-translate",
|
||||
a(
|
||||
cls := "navbar-brand",
|
||||
href := "https://demos.creative-tim.com/blk-design-system/index.html",
|
||||
rel := "tooltip",
|
||||
title := "",
|
||||
attr("data-placement") := "bottom",
|
||||
target := "_blank",
|
||||
div("OutwatchApp")
|
||||
),
|
||||
button(
|
||||
cls := "navbar-toggler navbar-toggler toggled collapsed",
|
||||
attr("data-toggle") := "collapse",
|
||||
attr("data-target") := "#navigation",
|
||||
attr("aria-controls") := "navigation-index",
|
||||
attr("aria-expanded") := "false",
|
||||
attr("aria-label") := "Toggle navigation",
|
||||
div(cls := "navbar-toggler-bar bar1"),
|
||||
div(cls := "navbar-toggler-bar bar2"),
|
||||
div(cls := "navbar-toggler-bar bar3")
|
||||
)
|
||||
),
|
||||
div(
|
||||
cls := "navbar-collapse justify-content-end collapse",
|
||||
idAttr := "navigation",
|
||||
div(
|
||||
cls := "navbar-collapse-header",
|
||||
div(
|
||||
cls := "row",
|
||||
div(
|
||||
cls := "col-6 collapse-brand",
|
||||
a("BLK•")
|
||||
),
|
||||
div(
|
||||
cls := "col-6 collapse-close text-right",
|
||||
button(
|
||||
`type` := "button",
|
||||
cls := "navbar-toggler collapsed",
|
||||
attr("data-toggle") := "collapse",
|
||||
attr("data-target") := "#navigation",
|
||||
attr("aria-controls") := "navigation-index",
|
||||
attr("aria-expanded") := "false",
|
||||
attr("aria-label") := "Toggle navigation",
|
||||
i(cls := "tim-icons icon-simple-remove")
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
ul(
|
||||
cls := "navbar-nav",
|
||||
li(
|
||||
cls := "nav-item active",
|
||||
router.link("/")(
|
||||
cls := "nav-link",
|
||||
"Home",
|
||||
div(cls := "sr-only", "(current)")
|
||||
)
|
||||
),
|
||||
li(
|
||||
cls := "nav-item",
|
||||
router
|
||||
.link("/some-page")(cls := "nav-link", "SomePage")
|
||||
),
|
||||
li(
|
||||
cls := "nav-item",
|
||||
router.link("/user/1")(cls := "nav-link", "User Home")
|
||||
),
|
||||
li(
|
||||
cls := "nav-item",
|
||||
router.link("/todomvc")(cls := "nav-link", "TodoMvc")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
div(
|
||||
cls := "container",
|
||||
router.render(resolver),
|
||||
router.watch()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.onErrorHandle(ex => UIO(ex.printStackTrace()))
|
||||
.as(ExitCode.Success)
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
package nova.monadic_sfx.ui.components.todo
|
||||
|
||||
import cats.kernel.Eq
|
||||
import com.softwaremill.quicklens._
|
||||
import io.circe.generic.JsonCodec
|
||||
import monix.bio.Task
|
||||
import nova.monadic_sfx.util.reactive.store.Reducer
|
||||
import nova.monadic_sfx.util.reactive.store.Store
|
||||
|
||||
case class Todo(id: Int, content: String)
|
||||
object Todo {
|
||||
implicit val eqForTodo = Eq.fromUniversalEquals[Todo]
|
||||
}
|
||||
|
||||
object TodoListStore {
|
||||
|
||||
@JsonCodec
|
||||
sealed trait Action
|
||||
case object Init extends Action
|
||||
case class Add(content: String) extends Action
|
||||
case class Edit(id: Int, content: String) extends Action
|
||||
case class Delete(id: Int) extends Action
|
||||
|
||||
private case class InternalAdd(content: String) extends Action
|
||||
private case object End extends Action
|
||||
object Action {
|
||||
implicit val eqForAction = Eq.fromUniversalEquals[Action]
|
||||
|
||||
}
|
||||
|
||||
case class State(todos: Vector[Todo], counter: Int)
|
||||
object State {
|
||||
implicit val eqForState = Eq.fromUniversalEquals[State]
|
||||
}
|
||||
|
||||
def reducer()(
|
||||
state: State,
|
||||
action: Action
|
||||
): (State, Option[Task[Action]]) =
|
||||
action match {
|
||||
case Init => (state, None)
|
||||
case Add(content) =>
|
||||
println("hello")
|
||||
val nextAction = Some(for {
|
||||
// do some validation
|
||||
// _ <- logger.debug(s"Received $content")
|
||||
_ <- Task(println(s"Received $content"))
|
||||
res <- Task.pure(InternalAdd(content))
|
||||
} yield res)
|
||||
(state, nextAction)
|
||||
case Edit(id, content) =>
|
||||
val condition: Todo => Boolean = _.id == id
|
||||
val nextState = state
|
||||
.modify(_.todos.eachWhere(condition))
|
||||
.using(_.copy(content = content))
|
||||
(nextState, None)
|
||||
case Delete(id) =>
|
||||
(state.copy(state.todos.filterNot(_.id == id)), None)
|
||||
|
||||
case InternalAdd(content) =>
|
||||
val nextState =
|
||||
state
|
||||
.modify(_.todos)
|
||||
.using(_ :+ Todo(state.counter, content))
|
||||
.modify(_.counter)
|
||||
.using(_ + 1)
|
||||
(nextState, Some(Task.pure(End)))
|
||||
case End => (state, None)
|
||||
}
|
||||
|
||||
def apply(): Task[Store[Action, State]] =
|
||||
Task.deferAction(implicit s =>
|
||||
for {
|
||||
store <-
|
||||
Store
|
||||
.createL[Action, State](
|
||||
Init,
|
||||
State(Vector.empty[Todo], 0),
|
||||
Reducer.withOptionalEffects(reducer() _)
|
||||
)
|
||||
} yield store
|
||||
)
|
||||
}
|
35
src/main/scala/outwatchapp/implicits/package.scala
Normal file
35
src/main/scala/outwatchapp/implicits/package.scala
Normal file
@ -0,0 +1,35 @@
|
||||
package outwatchapp
|
||||
|
||||
import colibri.Sink
|
||||
import nova.monadic_sfx.util.reactive.store.MonixProSubject
|
||||
import cats.effect.Sync
|
||||
import colibri.CreateProSubject
|
||||
import colibri.Source
|
||||
import colibri.LiftSink
|
||||
import monix.reactive.Observer
|
||||
import colibri.LiftSource
|
||||
import monix.reactive.Observable
|
||||
import colibri.ext.monix._
|
||||
import monix.execution.Scheduler
|
||||
import monix.execution.Ack
|
||||
|
||||
package object implicits {
|
||||
// implicit def sinkForMyMonixProSub[A, M] =
|
||||
// new Sink[MonixProSubject[A, M]] {}\
|
||||
// implicit val monixCreateProSubject = new CreateProSubject[MonixProSubject] {
|
||||
// @inline def from[SI[_]: Sink, SO[_]: Source, I, O](
|
||||
// sink: SI[I],
|
||||
// source: SO[O]
|
||||
// ): MonixProSubject[I, O] = MonixProSubject.from(
|
||||
// LiftSink[Observer].lift(sink),
|
||||
// LiftSource[Observable].lift(source)
|
||||
// )
|
||||
// }
|
||||
|
||||
implicit class Ops[A](val sink: Observer[A]) {
|
||||
@inline def liftSink[G[_]: LiftSink]: G[A] = LiftSink[G].lift(sink)
|
||||
}
|
||||
implicit class Ops2[A](val source: Observable[A])(implicit s: Scheduler) {
|
||||
@inline def liftSource[G[_]: LiftSource]: G[A] = LiftSource[G].lift(source)
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package nova.monadic_sfx.util.reactive.store
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
import io.circe.Encoder
|
||||
import io.circe.Printer
|
||||
import io.circe.generic.JsonCodec
|
||||
import io.circe.syntax._
|
||||
import monix.bio.Task
|
||||
import monix.reactive.Observable
|
||||
// object Middleware {
|
||||
// def apply[A,M,T](ob: Observable[(A,M)], cb: (A,M) => T): Observable[(A,M)] = ob
|
||||
// }
|
||||
|
||||
@JsonCodec
|
||||
final case class StoreInfo[A](
|
||||
name: String,
|
||||
action: A,
|
||||
time: LocalDateTime
|
||||
)
|
||||
|
||||
object StoreInfo {
|
||||
val printer = Printer.noSpaces
|
||||
}
|
||||
|
||||
object Middlewares {}
|
@ -0,0 +1,23 @@
|
||||
package nova.monadic_sfx.util.reactive.store
|
||||
|
||||
import scala.concurrent.Future
|
||||
|
||||
import monix.execution.Ack
|
||||
import monix.execution.Cancelable
|
||||
import monix.reactive.Observable
|
||||
import monix.reactive.Observer
|
||||
import monix.reactive.observers.Subscriber
|
||||
|
||||
object MonixProSubject {
|
||||
def from[I, O](
|
||||
observer: Observer[I],
|
||||
observable: Observable[O]
|
||||
): MonixProSubject[I, O] =
|
||||
new Observable[O] with Observer[I] {
|
||||
override def onNext(elem: I): Future[Ack] = observer.onNext(elem)
|
||||
override def onError(ex: Throwable): Unit = observer.onError(ex)
|
||||
override def onComplete(): Unit = observer.onComplete()
|
||||
override def unsafeSubscribeFn(subscriber: Subscriber[O]): Cancelable =
|
||||
observable.unsafeSubscribeFn(subscriber)
|
||||
}
|
||||
}
|
39
src/main/scala/outwatchapp/util/reactive/store/Reducer.scala
Normal file
39
src/main/scala/outwatchapp/util/reactive/store/Reducer.scala
Normal file
@ -0,0 +1,39 @@
|
||||
package nova.monadic_sfx.util.reactive.store
|
||||
|
||||
import cats.implicits._
|
||||
import monix.reactive.Observable
|
||||
import monix.reactive.ObservableLike
|
||||
|
||||
object Reducer {
|
||||
|
||||
/**
|
||||
* Creates a Reducer which yields a new State, as-well as an Observable of Effects
|
||||
* Effects are Actions which will be executed after the Action that caused them to occur.
|
||||
* This is accomplished by subscribing to the Effects Observable within the stores scan loop.
|
||||
*
|
||||
* CAUTION: There is currently a bug which causes the Effect-States to emit,
|
||||
* before the State of the action that caused the effects is emitted.
|
||||
* However, this only effects immediate emissions of the Effects Observable, delayed emissions should be fine.
|
||||
* @param f The Reducing Function returning the (Model, Effects) tuple.
|
||||
*/
|
||||
def withEffects[F[_]: ObservableLike, A, M](
|
||||
f: (M, A) => (M, F[A])
|
||||
): Reducer[A, M] = (s: M, a: A) => f(s, a).map(ObservableLike[F].apply)
|
||||
|
||||
/**
|
||||
* Creates a reducer which just transforms the state, without additional effects.
|
||||
*/
|
||||
def apply[A, M](f: (M, A) => M): Reducer[A, M] =
|
||||
(s: M, a: A) => f(s, a) -> Observable.empty
|
||||
|
||||
/**
|
||||
* Creates a Reducer with an optional effect.
|
||||
*/
|
||||
def withOptionalEffects[F[_]: ObservableLike, A, M](
|
||||
f: (M, A) => (M, Option[F[A]])
|
||||
): Reducer[A, M] =
|
||||
(s: M, a: A) =>
|
||||
f(s, a).map(
|
||||
_.fold[Observable[A]](Observable.empty)(ObservableLike[F].apply)
|
||||
)
|
||||
}
|
54
src/main/scala/outwatchapp/util/reactive/store/Store.scala
Normal file
54
src/main/scala/outwatchapp/util/reactive/store/Store.scala
Normal file
@ -0,0 +1,54 @@
|
||||
package nova.monadic_sfx.util.reactive.store
|
||||
|
||||
import monix.bio.Task
|
||||
import monix.eval.Coeval
|
||||
import monix.reactive.OverflowStrategy
|
||||
import monix.reactive.subjects.ConcurrentSubject
|
||||
|
||||
object Store {
|
||||
def createL[A, M](
|
||||
initialAction: A,
|
||||
initialState: M,
|
||||
reducer: Reducer[A, M],
|
||||
middlewares: Seq[Middleware[A, M]] = Seq.empty,
|
||||
overflowStrategy: OverflowStrategy.Synchronous[A] =
|
||||
OverflowStrategy.DropOld(50)
|
||||
): Task[Store[A, M]] =
|
||||
Task.deferAction { implicit s =>
|
||||
Task {
|
||||
val subject = ConcurrentSubject.publish[A](overflowStrategy)
|
||||
|
||||
val fold: ((A, M), A) => Coeval[(A, M)] = {
|
||||
case ((_, state), action) =>
|
||||
Coeval {
|
||||
val (newState, effects) = reducer(state, action)
|
||||
|
||||
effects.subscribe(subject.onNext _)
|
||||
|
||||
action -> newState
|
||||
}
|
||||
}
|
||||
|
||||
val obs = subject
|
||||
.scanEval0F[Coeval, (A, M)](
|
||||
Coeval.pure(initialAction -> initialState)
|
||||
)(fold)
|
||||
|
||||
val res = middlewares
|
||||
.foldLeft(obs) {
|
||||
case (obs, middleware) => middleware(obs)
|
||||
}
|
||||
.doOnNextF(i => Coeval(println(s"Emitted item 1: $i")))
|
||||
.behavior(initialAction -> initialState)
|
||||
.refCount
|
||||
|
||||
// .doOnNextF(i => Coeval(println(s"Emitted item 2: $i")))
|
||||
|
||||
MonixProSubject.from(
|
||||
subject,
|
||||
res
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
18
src/main/scala/outwatchapp/util/reactive/store/package.scala
Normal file
18
src/main/scala/outwatchapp/util/reactive/store/package.scala
Normal file
@ -0,0 +1,18 @@
|
||||
package nova.monadic_sfx.util.reactive
|
||||
|
||||
import monix.reactive.Observable
|
||||
import monix.reactive.Observer
|
||||
|
||||
package object store {
|
||||
type MonixProSubject[-I, +O] = Observable[O] with Observer[I]
|
||||
type Middleware[A, M] = Observable[(A, M)] => Observable[(A, M)]
|
||||
type Store[A, M] = MonixProSubject[A, (A, M)]
|
||||
|
||||
/**
|
||||
* A Function that applies an Action onto the Stores current state.
|
||||
* @param reducer The reducing function
|
||||
* @tparam A The Action Type
|
||||
* @tparam M The Model Type
|
||||
*/
|
||||
type Reducer[A, M] = (M, A) => (M, Observable[A])
|
||||
}
|
23
src/test/scala/outwatchapp/JSDomSpec.scala
Normal file
23
src/test/scala/outwatchapp/JSDomSpec.scala
Normal file
@ -0,0 +1,23 @@
|
||||
package outwatchapp
|
||||
|
||||
import org.scalajs.dom._
|
||||
import org.scalatest.BeforeAndAfterEach
|
||||
import org.scalatest.flatspec.AnyFlatSpec
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
abstract class JSDomSpec
|
||||
extends AnyFlatSpec
|
||||
with Matchers
|
||||
with BeforeAndAfterEach {
|
||||
|
||||
override def beforeEach(): Unit = {
|
||||
|
||||
document.body.innerHTML = ""
|
||||
|
||||
// prepare body with <div id="app"></div>
|
||||
val root = document.createElement("div")
|
||||
root.id = "app"
|
||||
document.body.appendChild(root)
|
||||
()
|
||||
}
|
||||
}
|
18
src/test/scala/outwatchapp/OutwatchtestSpec.scala
Normal file
18
src/test/scala/outwatchapp/OutwatchtestSpec.scala
Normal file
@ -0,0 +1,18 @@
|
||||
package outwatchapp
|
||||
|
||||
import org.scalajs.dom._
|
||||
import outwatch._
|
||||
import outwatch.dsl._
|
||||
|
||||
import cats.effect.IO
|
||||
|
||||
class OutwatchtestSpec extends JSDomSpec {
|
||||
|
||||
"You" should "probably add some tests" in {
|
||||
|
||||
val message = "Hello World!"
|
||||
OutWatch.renderInto[IO]("#app", h1(message)).unsafeRunSync()
|
||||
|
||||
document.body.innerHTML.contains(message) shouldBe true
|
||||
}
|
||||
}
|
25
webpack.config.dev.js
Normal file
25
webpack.config.dev.js
Normal file
@ -0,0 +1,25 @@
|
||||
var webpack = require('webpack');
|
||||
|
||||
module.exports = require('./scalajs.webpack.config');
|
||||
|
||||
const Path = require('path');
|
||||
const rootDir = Path.resolve(__dirname, '../../../..');
|
||||
// module.exports.module = {
|
||||
// ...module.exports.module,
|
||||
// rules: module.exports.module.rules.concat([
|
||||
// {
|
||||
// test: /\.css$/i,
|
||||
// use: ['style-loader', 'css-loader'],
|
||||
// },
|
||||
// ]),
|
||||
// };
|
||||
module.exports.devServer = {
|
||||
contentBase: [
|
||||
Path.resolve(__dirname, 'dev'), // fastOptJS output
|
||||
Path.resolve(rootDir, 'assets') // project root containing index.html
|
||||
],
|
||||
watchContentBase: true,
|
||||
hot: false,
|
||||
hotOnly: false, // only reload when build is successful
|
||||
inline: true // live reloading
|
||||
};
|
13
webpack.config.js
Normal file
13
webpack.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
var webpack = require('webpack');
|
||||
|
||||
module.exports = require('./scalajs.webpack.config');
|
||||
|
||||
module.exports.module = {
|
||||
...module.exports.module,
|
||||
rules: module.exports.module.rules.concat([
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
]),
|
||||
};
|
Loading…
Reference in New Issue
Block a user