Rohan Sircar
3 years ago
commit
dc1bb4ad31
29 changed files with 5453 additions and 0 deletions
-
7.gitignore
-
1.scalafmt.conf
-
23README.md
-
25assets/app/index.html
-
13assets/index.html
-
125build.sbt
-
1project/build.properties
-
4project/plugins.sbt
-
145src/main/scala/outwatch/router/Router.scala
-
10src/main/scala/outwatch/router/dsl/Extractors.scala
-
294src/main/scala/outwatch/router/dsl/Path.scala
-
5src/main/scala/outwatch/router/dsl/package.scala
-
10src/main/scala/outwatch/router/package.scala
-
48src/main/scala/outwatch/util/MonixWebSocket.scala
-
39src/main/scala/outwatch/util/WebSocket2.scala
-
19src/main/scala/outwatchapp/IOUtils.scala
-
291src/main/scala/outwatchapp/OutwatchApp.scala
-
83src/main/scala/outwatchapp/components/todo/TodoListStore.scala
-
35src/main/scala/outwatchapp/implicits/package.scala
-
26src/main/scala/outwatchapp/util/reactive/store/Middlewares.scala
-
23src/main/scala/outwatchapp/util/reactive/store/MonixProSubject.scala
-
39src/main/scala/outwatchapp/util/reactive/store/Reducer.scala
-
54src/main/scala/outwatchapp/util/reactive/store/Store.scala
-
18src/main/scala/outwatchapp/util/reactive/store/package.scala
-
23src/test/scala/outwatchapp/JSDomSpec.scala
-
18src/test/scala/outwatchapp/OutwatchtestSpec.scala
-
25webpack.config.dev.js
-
13webpack.config.js
-
4036yarn.lock
@ -0,0 +1,7 @@ |
|||
target/ |
|||
.idea/ |
|||
.metals |
|||
metals.sbt |
|||
.vscode |
|||
.bsp |
|||
.bloop |
@ -0,0 +1 @@ |
|||
version = "2.7.4" |
@ -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. |
@ -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> |
@ -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> |
@ -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 |
|||
) |
|||
} |
@ -0,0 +1 @@ |
|||
sbt.version=1.4.6 |
@ -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") |
@ -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) |
|||
} |
|||
} |
@ -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 |
|||
} |
|||
} |
|||
} |
@ -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 |
|||
} |
|||
} |
@ -0,0 +1,5 @@ |
|||
package outwatch.router |
|||
|
|||
// import outwatch.router.dsl.Extractors |
|||
|
|||
package object dsl extends Extractors |
@ -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] |
|||
} |
@ -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 = () |
|||
} |
|||
} |
|||
} |
|||
} |
@ -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) |
|||
} |
|||
} |
|||
} |
|||
} |
@ -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) |
|||
|
|||
} |
|||
} |
@ -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 |
|||
) |
|||
} |
@ -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) |
|||
} |
|||
} |
@ -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) |
|||
) |
|||
} |
@ -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 |
|||
) |
|||
} |
|||
} |
|||
|
|||
} |
@ -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]) |
|||
} |
@ -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) |
|||
() |
|||
} |
|||
} |
@ -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 |
|||
} |
|||
} |
@ -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
|
|||
}; |
@ -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'], |
|||
}, |
|||
]), |
|||
}; |
4036
yarn.lock
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue