First commit

This commit is contained in:
Rohan Sircar 2020-12-27 20:28:19 +05:30
commit dc1bb4ad31
29 changed files with 5453 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
target/
.idea/
.metals
metals.sbt
.vscode
.bsp
.bloop

1
.scalafmt.conf Normal file
View File

@ -0,0 +1 @@
version = "2.7.4"

23
README.md Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
sbt.version=1.4.6

4
project/plugins.sbt Normal file
View 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")

View 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)
}
}

View 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
}
}
}

View 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
}
}

View File

@ -0,0 +1,5 @@
package outwatch.router
// import outwatch.router.dsl.Extractors
package object dsl extends Extractors

View 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]
}

View 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 = ()
}
}
}
}

View 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)
}
}
}
}

View 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)
}
}

View 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)
}
}

View File

@ -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
)
}

View 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)
}
}

View File

@ -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 {}

View File

@ -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)
}
}

View 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)
)
}

View 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
)
}
}
}

View 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])
}

View 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)
()
}
}

View 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
View 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
View 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'],
},
]),
};

4036
yarn.lock Normal file

File diff suppressed because it is too large Load Diff