diff --git a/example/scalalib/web/10-todo-webapp-cask-scalasql/build.mill b/example/scalalib/web/10-todo-webapp-cask-scalasql/build.mill new file mode 100644 index 00000000000..b0590c6e051 --- /dev/null +++ b/example/scalalib/web/10-todo-webapp-cask-scalasql/build.mill @@ -0,0 +1,49 @@ +package build + +import mill._, scalalib._ + +object `package` extends RootModule with ScalaModule { + def scalaVersion = "2.13.8" + + override def ivyDeps = Agg( + ivy"com.lihaoyi::cask:0.9.1", + ivy"com.lihaoyi::scalatags:0.13.1", + ivy"com.lihaoyi::upickle:3.1.0", + ivy"com.lihaoyi::scalasql:0.1.19", + ivy"com.h2database:h2:2.2.224" + ) + + object test extends ScalaTests { + def testFramework = "utest.runner.Framework" + + override def ivyDeps = Agg( + ivy"com.lihaoyi::utest:0.8.5", + ivy"com.lihaoyi::requests:0.6.9", + ivy"com.dimafeng::testcontainers-scala-postgresql:0.43.0", + ivy"com.dimafeng::testcontainers-scala-scalatest:0.43.0", + ivy"org.testcontainers:postgresql:1.19.1" + ) + } +} + +// This example demonstrates how to set up a simple Scala webserver +// implementing the popular Todo-MVC demo application. It uses the +// Cask web framework to handle HTTP requests and Scalasql to interact +// with an in-memory H2 database. It includes a test suite +// that spins up the web server locally, creates a postgres testcontainer +// and makes HTTP requests against it. + +/** Usage + * + * > ./mill test + * + webapp.WebAppTests.simpleRequest... + * + * > ./mill runBackground + * + * > curl http://localhost:8080 + * ...What needs to be done... + * ... + * + * > ./mill clean runBackground + * + */ diff --git a/example/scalalib/web/10-todo-webapp-cask-scalasql/resources/webapp/index.css b/example/scalalib/web/10-todo-webapp-cask-scalasql/resources/webapp/index.css new file mode 100644 index 00000000000..07ef4a160e3 --- /dev/null +++ b/example/scalalib/web/10-todo-webapp-cask-scalasql/resources/webapp/index.css @@ -0,0 +1,378 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; + font-weight: 300; +} + +button, +input[type="checkbox"] { + outline: none; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + outline: none; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +label[for='toggle-all'] { + display: none; +} + +.toggle-all { + position: absolute; + top: -55px; + left: -12px; + width: 60px; + height: 34px; + text-align: center; + border: none; /* Mobile Safari */ +} + +.toggle-all:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 13px 17px 12px 17px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li .toggle:checked:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li label { + white-space: pre-line; + word-break: break-all; + padding: 15px 60px 15px 15px; + margin-left: 45px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a.selected, +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; + position: relative; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } + + .toggle-all { + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + -webkit-appearance: none; + appearance: none; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/example/scalalib/web/10-todo-webapp-cask-scalasql/resources/webapp/main.js b/example/scalalib/web/10-todo-webapp-cask-scalasql/resources/webapp/main.js new file mode 100644 index 00000000000..4a7617f50c2 --- /dev/null +++ b/example/scalalib/web/10-todo-webapp-cask-scalasql/resources/webapp/main.js @@ -0,0 +1,70 @@ +var state = "all"; + +var todoApp = document.getElementsByClassName("todoapp")[0]; +function postFetchUpdate(url){ + fetch(url, { + method: "POST", + }) + .then(function(response){ return response.text()}) + .then(function (text) { + todoApp.innerHTML = text; + initListeners() + }) +} + +function bindEvent(cls, url, endState){ + + document.getElementsByClassName(cls)[0].addEventListener( + "mousedown", + function(evt){ + postFetchUpdate(url) + if (endState) state = endState + } + ); +} + +function bindIndexedEvent(cls, func){ + Array.from(document.getElementsByClassName(cls)).forEach( function(elem) { + elem.addEventListener( + "mousedown", + function(evt){ + postFetchUpdate(func(elem.getAttribute("data-todo-index"))) + } + ) + }); +} + +function initListeners(){ + bindIndexedEvent( + "destroy", + function(index){return "/delete/" + state + "/" + index} + ); + bindIndexedEvent( + "toggle", + function(index){return "/toggle/" + state + "/" + index} + ); + bindEvent("toggle-all", "/toggle-all/" + state); + bindEvent("todo-all", "/list/all", "all"); + bindEvent("todo-active", "/list/active", "active"); + bindEvent("todo-completed", "/list/completed", "completed"); + bindEvent("clear-completed", "/clear-completed/" + state); + var newTodoInput = document.getElementsByClassName("new-todo")[0]; + newTodoInput.addEventListener( + "keydown", + function(evt){ + if (evt.keyCode === 13) { + fetch("/add/" + state, { + method: "POST", + body: newTodoInput.value + }) + .then(function(response){ return response.text()}) + .then(function (text) { + newTodoInput.value = ""; + todoApp.innerHTML = text; + initListeners() + }) + } + } + ); +} +initListeners() \ No newline at end of file diff --git a/example/scalalib/web/10-todo-webapp-cask-scalasql/src/WebApp.scala b/example/scalalib/web/10-todo-webapp-cask-scalasql/src/WebApp.scala new file mode 100644 index 00000000000..f3f2a32988a --- /dev/null +++ b/example/scalalib/web/10-todo-webapp-cask-scalasql/src/WebApp.scala @@ -0,0 +1,207 @@ +package webapp + +import scalasql.H2Dialect._ +import scalasql.core.DbClient +import scalasql.{Sc, Table} +import scalatags.Text.all._ +import scalatags.Text.tags2 +import webapp.WebApp.DB.Todos + +object WebApp extends cask.MainRoutes { + case class Todo(checked: Boolean, text: String) + + object Todo { + implicit def todoRW: upickle.default.ReadWriter[Todo] = upickle.default.macroRW[Todo] + } + + object DB { + + // Table Definition and its corresponding ORM mapping + case class Todos[T[_]]( + id: T[Int], + checked: T[Boolean], + text: T[String] + ) + + object Todos extends Table[Todos] + + def getDatabaseClient: DbClient.DataSource = { + // The example H2 database comes from the library `com.h2database:h2:2.2.224` + val dataSource = new org.h2.jdbcx.JdbcDataSource + dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1") + + new scalasql.DbClient.DataSource( + dataSource, + config = new scalasql.Config {} + ) + } + + def initializeSchema(h2Client: scalasql.DbClient.DataSource): Unit = { + h2Client.transaction { db => + db.updateRaw( + """ + CREATE TABLE IF NOT EXISTS todos ( + id INT AUTO_INCREMENT PRIMARY KEY, + checked BOOLEAN NOT NULL, + text VARCHAR NOT NULL + ); + """ + ) + println("Schema initialized (if not exists).") // Optional logging + } + } + + } + + lazy val dbClient: DbClient.DataSource = DB.getDatabaseClient + DB.initializeSchema(dbClient) + + @cask.post("/list/:state") + def list(state: String) = { + val todos: Seq[Todo] = dbClient.transaction { db => + db.run(Todos.select).sortBy(_.id) + }.map(r => Todo(r.checked, r.text)) + + renderBody(state, todos) + } + + @cask.post("/add/:state") + def add(state: String, request: cask.Request) = { + dbClient.transaction { db => + db.run( + Todos.insert.batched(_.checked, _.text)( + (false, state) + ) + ) + } + list(state) + } + + @cask.post("/delete/:state/:index") + def delete(state: String, index: Int) = { + dbClient.transaction { db => + db.run(Todos.delete(_.id === index)) + } + list(state) + } + + @cask.post("/toggle/:state/:index") + def toggle(state: String, index: Int) = { + val value1: Sc[Boolean] = dbClient.transaction { db => + db.run(Todos.select.filter(_.id === index)).map(_.checked).head + } + list(state) + } + + @cask.post("/clear-completed/:state") + def clearCompleted(state: String) = { + dbClient.transaction { db => + db.run(Todos.delete(_.checked === true)) + } + list(state) + } + + @cask.post("/toggle-all/:state") + def toggleAll(state: String) = { + dbClient.transaction { db => + db.updateRaw( + """ + UPDATE todos SET checked = CASE + WHEN (SELECT COUNT(*) FROM todos WHERE checked = TRUE) > 0 THEN FALSE + ELSE TRUE + END; + | + |""".stripMargin + ) + } + list(state) + } + + def renderBody(state: String, todos: Seq[Todo]) /*: scalatags.Text.TypedTag[String] */ = { + + val filteredTodos = state match { + case "all" => todos.zipWithIndex + case "active" => todos.zipWithIndex.filter(!_._1.checked) + case "completed" => todos.zipWithIndex.filter(_._1.checked) + } + div( + header( + cls := "header", + h1("todos"), + input(cls := "new-todo", placeholder := "What needs to be done?", autofocus := "") + ), + tags2.section( + cls := "main", + input( + id := "toggle-all", + cls := "toggle-all", + `type` := "checkbox", + if (todos.filter(_.checked).size != 0) checked else () + ), + label(`for` := "toggle-all", "Mark all as complete"), + ul( + cls := "todo-list", + for ((todo, index) <- filteredTodos) yield li( + if (todo.checked) cls := "completed" else (), + div( + cls := "view", + input( + cls := "toggle", + `type` := "checkbox", + if (todo.checked) checked else (), + data("todo-index") := index + ), + label(todo.text), + button(cls := "destroy", data("todo-index") := index) + ), + input(cls := "edit", value := todo.text) + ) + ) + ), + footer( + cls := "footer", + span(cls := "todo-count", strong(todos.filter(!_.checked).size), " items left"), + ul( + cls := "filters", + li(cls := "todo-all", a(if (state == "all") cls := "selected" else (), "All")), + li(cls := "todo-active", a(if (state == "active") cls := "selected" else (), "Active")), + li( + cls := "todo-completed", + a(if (state == "completed") cls := "selected" else (), "Completed") + ) + ), + button(cls := "clear-completed", "Clear completed") + ) + ) + } + + @cask.get("/") + def index() = { + doctype("html")( + html( + lang := "en", + head( + meta(charset := "utf-8"), + meta(name := "viewport", content := "width=device-width, initial-scale=1"), + tags2.title("Template • TodoMVC"), + link(rel := "stylesheet", href := "/static/index.css") + ), + body( + tags2.section(cls := "todoapp", list("all")), + footer( + cls := "info", + p("Double-click to edit a todo"), + p("Created by ", a(href := "http://todomvc.com", "Li Haoyi")), + p("Part of ", a(href := "http://todomvc.com", "TodoMVC")) + ), + script(src := "/static/main.js") + ) + ) + ) + } + + @cask.staticResources("/static") + def static() = "webapp" + + initialize() +} diff --git a/example/scalalib/web/10-todo-webapp-cask-scalasql/test/src/WebAppIntegrationTests.scala b/example/scalalib/web/10-todo-webapp-cask-scalasql/test/src/WebAppIntegrationTests.scala new file mode 100644 index 00000000000..c73aeef2e42 --- /dev/null +++ b/example/scalalib/web/10-todo-webapp-cask-scalasql/test/src/WebAppIntegrationTests.scala @@ -0,0 +1,77 @@ +package webapp + +import utest._ +import requests._ +import com.dimafeng.testcontainers.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName +import scala.concurrent.blocking +import scala.sys.process._ +import scala.concurrent.duration._ +import scala.util.Try + +object WebAppIntegrationTests extends TestSuite { + + val tests = Tests { + var serverPid: Option[Process] = None + val serverPort = 8080 + + val pgContainer = PostgreSQLContainer( + dockerImageNameOverride = DockerImageName.parse("postgres:15"), + databaseName = "testdb", + username = "postgres", + password = "password" + ) + + def waitForServer(port: Int, timeout: FiniteDuration = 100.seconds): Boolean = { + val deadline = timeout.fromNow + while (deadline.hasTimeLeft()) { + Try(requests.get(s"http://localhost:$port/")).toOption match { + case Some(response) if response.statusCode == 200 => return true + case _ => + Thread.sleep(500) + } + } + false + } + + test("start postgres container and app server") { + pgContainer.start() + + val envVars = Seq( + "DB_URL" -> pgContainer.jdbcUrl, + "DB_USER" -> pgContainer.username, + "DB_PASS" -> pgContainer.password + ) + + val cmd = Seq("mill", "runBackground") + val pb = Process(cmd, None, envVars: _*) + serverPid = Some(pb.run()) + + val started = waitForServer(serverPort) + require(started, "Server did not start in time") + } + + test("add and list todos") { + val response = requests.post(s"http://localhost:$serverPort/add/all", data = "") + assert(response.statusCode == 200) + assert(response.text.contains("What needs to be done")) + + val response2 = requests.post(s"http://localhost:$serverPort/list/all", data = "") + assert(response2.text.contains("What needs to be done")) + } + + test("toggle and delete todo") { + // Toggle doesn't change DB but should still return 200 + val response = requests.post(s"http://localhost:$serverPort/toggle/all/1", data = "") + assert(response.statusCode == 200) + + val deleteResp = requests.post(s"http://localhost:$serverPort/delete/all/1", data = "") + assert(deleteResp.statusCode == 200) + } + + test("cleanup") { + serverPid.foreach(_.destroy()) + pgContainer.stop() + } + } +} diff --git a/example/scalalib/web/10-todo-webapp-cask-scalasql/test/src/WebAppTests.scala b/example/scalalib/web/10-todo-webapp-cask-scalasql/test/src/WebAppTests.scala new file mode 100644 index 00000000000..609c1e2c194 --- /dev/null +++ b/example/scalalib/web/10-todo-webapp-cask-scalasql/test/src/WebAppTests.scala @@ -0,0 +1,69 @@ +package webapp + +import scalasql.H2Dialect._ +import utest._ +import webapp.WebApp.{DB, Todo} + +object WebAppTests extends TestSuite { + + val h2Client = DB.getDatabaseClient + DB.initializeSchema(h2Client) + + val tests = Tests { + test("insert and list todos") { + h2Client.transaction { db => + db.run(DB.Todos.insert.batched(_.checked, _.text)((false, "Learn Scala"))) + } + + val todos = h2Client.transaction { db => + db.run(DB.Todos.select).map(r => Todo(r.checked, r.text)) + } + + assert(todos.size == 1) + assert(todos.head.text == "Learn Scala") + assert(!todos.head.checked) + } + + test("delete todo") { + h2Client.transaction { db => + db.run(DB.Todos.insert.batched(_.checked, _.text)((false, "To Delete"))) + } + + val id = h2Client.transaction { db => + db.run(DB.Todos.select).find(_.text == "To Delete").get.id + } + + h2Client.transaction { db => + db.run(DB.Todos.delete(_.id === id)) + } + + val todos = h2Client.transaction { db => + db.run(DB.Todos.select) + } + + assert(todos.forall(_.text != "To Delete")) + } + + test("toggle state") { + h2Client.transaction { db => + db.run(DB.Todos.insert.batched(_.checked, _.text)((false, "To Toggle"))) + } + + val todo = h2Client.transaction { db => + db.run(DB.Todos.select).find(_.text == "To Toggle").get + } + + val newState = !todo.checked + + h2Client.transaction { db => + db.updateRaw(s"UPDATE todos SET checked = ${newState} WHERE id = ${todo.id}") + } + + val updated = h2Client.transaction { db => + db.run(DB.Todos.select).find(_.id == todo.id).get + } + + assert(updated.checked == newState) + } + } +} diff --git a/example/scalalib/web/11-todo-http4s-scalasql/build.mill b/example/scalalib/web/11-todo-http4s-scalasql/build.mill new file mode 100644 index 00000000000..4eeca42e1c4 --- /dev/null +++ b/example/scalalib/web/11-todo-http4s-scalasql/build.mill @@ -0,0 +1,54 @@ +package build +import mill._, scalalib._ +import coursier.ivyRepositoryString + +object `package` extends RootModule with ScalaModule { + def scalaVersion = "2.13.8" + def ivyDeps = Agg( + Dep.parse(ivy"org.http4s::http4s-ember-server::0.23.30", scalaVersion()).right.get, + Dep.parse(ivy"org.http4s::http4s-dsl::0.23.30", scalaVersion()).right.get, + Dep.parse(ivy"org.http4s::http4s-scalatags::0.25.2", scalaVersion()).right.get, + Dep.parse(ivy"io.circe::circe-generic::0.14.10", scalaVersion()).right.get, + Dep.parse(ivy"com.lihaoyi::scalasql:0.1.19", scalaVersion()).right.get, + Dep.parse(ivy"com.h2database:h2:2.2.224", scalaVersion()).right.get + ) + + object test extends ScalaTests { + def testFramework = "utest.runner.Framework" + def scalaVersion = `package`.scalaVersion + + def ivyDeps = Agg( + Dep.parse(ivy"com.lihaoyi::utest::0.8.5", scalaVersion()).right.get, + Dep.parse(ivy"org.typelevel::cats-effect-testing-utest::1.6.0", scalaVersion()).right.get, + Dep.parse(ivy"org.http4s::http4s-client::0.23.30", scalaVersion()).right.get, + Dep.parse( + ivy"com.dimafeng::testcontainers-scala-postgresql:0.43.0", + scalaVersion() + ).right.get, + Dep.parse(ivy"com.dimafeng::testcontainers-scala-scalatest:0.43.0", scalaVersion()).right.get, + Dep.parse(ivy"org.testcontainers:postgresql:1.19.1", scalaVersion()).right.get + ) + } +} + +// This example demonstrates how to set up a Scala webserver using the popular +// https://http4s.org/[Http4S] web framework and associated libraries. +// implementing the popular Todo-MVC demo application. It also uses the popular +// https://github.com/com-lihaoyi/scalasql/[Scalasql] typesafe ORM library for +// querying of in memory H2 database. It includes a test suite +// that spins up the web server locally and makes HTTP requests against it. + +/** Usage + +> ./mill test ++ webapp.WebAppTests.simpleRequest... + +> ./mill runBackground + +> curl http://localhost:8084 +...What needs to be done... +... + +> ./mill clean runBackground + +*/ diff --git a/example/scalalib/web/11-todo-http4s-scalasql/mill b/example/scalalib/web/11-todo-http4s-scalasql/mill new file mode 100755 index 00000000000..f3497620e7d --- /dev/null +++ b/example/scalalib/web/11-todo-http4s-scalasql/mill @@ -0,0 +1,265 @@ +#!/usr/bin/env sh + +# This is a wrapper script, that automatically download mill from GitHub release pages +# You can give the required mill version with --mill-version parameter +# If no version is given, it falls back to the value of DEFAULT_MILL_VERSION +# +# Original Project page: https://github.com/lefou/millw +# Script Version: 0.4.12 +# +# If you want to improve this script, please also contribute your changes back! +# +# Licensed under the Apache License, Version 2.0 + +set -e + +if [ -z "${DEFAULT_MILL_VERSION}" ] ; then + DEFAULT_MILL_VERSION=0.12.10 +fi + + +if [ -z "${GITHUB_RELEASE_CDN}" ] ; then + GITHUB_RELEASE_CDN="" +fi + + +MILL_REPO_URL="https://github.com/com-lihaoyi/mill" + +if [ -z "${CURL_CMD}" ] ; then + CURL_CMD=curl +fi + +# Explicit commandline argument takes precedence over all other methods +if [ "$1" = "--mill-version" ] ; then + shift + if [ "x$1" != "x" ] ; then + MILL_VERSION="$1" + shift + else + echo "You specified --mill-version without a version." 1>&2 + echo "Please provide a version that matches one provided on" 1>&2 + echo "${MILL_REPO_URL}/releases" 1>&2 + false + fi +fi + +# Please note, that if a MILL_VERSION is already set in the environment, +# We reuse it's value and skip searching for a value. + +# If not already set, read .mill-version file +if [ -z "${MILL_VERSION}" ] ; then + if [ -f ".mill-version" ] ; then + MILL_VERSION="$(tr '\r' '\n' < .mill-version | head -n 1 2> /dev/null)" + elif [ -f ".config/mill-version" ] ; then + MILL_VERSION="$(tr '\r' '\n' < .config/mill-version | head -n 1 2> /dev/null)" + fi +fi + +MILL_USER_CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/mill" + +if [ -z "${MILL_DOWNLOAD_PATH}" ] ; then + MILL_DOWNLOAD_PATH="${MILL_USER_CACHE_DIR}/download" +fi + +# If not already set, try to fetch newest from Github +if [ -z "${MILL_VERSION}" ] ; then + # TODO: try to load latest version from release page + echo "No mill version specified." 1>&2 + echo "You should provide a version via '.mill-version' file or --mill-version option." 1>&2 + + mkdir -p "${MILL_DOWNLOAD_PATH}" + LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" 2>/dev/null || ( + # we might be on OSX or BSD which don't have -d option for touch + # but probably a -A [-][[hh]mm]SS + touch "${MILL_DOWNLOAD_PATH}/.expire_latest"; touch -A -010000 "${MILL_DOWNLOAD_PATH}/.expire_latest" + ) || ( + # in case we still failed, we retry the first touch command with the intention + # to show the (previously suppressed) error message + LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" + ) + + # POSIX shell variant of bash's -nt operator, see https://unix.stackexchange.com/a/449744/6993 + # if [ "${MILL_DOWNLOAD_PATH}/.latest" -nt "${MILL_DOWNLOAD_PATH}/.expire_latest" ] ; then + if [ -n "$(find -L "${MILL_DOWNLOAD_PATH}/.latest" -prune -newer "${MILL_DOWNLOAD_PATH}/.expire_latest")" ]; then + # we know a current latest version + MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) + fi + + if [ -z "${MILL_VERSION}" ] ; then + # we don't know a current latest version + echo "Retrieving latest mill version ..." 1>&2 + LANG=C ${CURL_CMD} -s -i -f -I ${MILL_REPO_URL}/releases/latest 2> /dev/null | grep --ignore-case Location: | sed s'/^.*tag\///' | tr -d '\r\n' > "${MILL_DOWNLOAD_PATH}/.latest" + MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) + fi + + if [ -z "${MILL_VERSION}" ] ; then + # Last resort + MILL_VERSION="${DEFAULT_MILL_VERSION}" + echo "Falling back to hardcoded mill version ${MILL_VERSION}" 1>&2 + else + echo "Using mill version ${MILL_VERSION}" 1>&2 + fi +fi + +MILL_NATIVE_SUFFIX="-native" +FULL_MILL_VERSION=$MILL_VERSION +ARTIFACT_SUFFIX="" +case "$MILL_VERSION" in + *"$MILL_NATIVE_SUFFIX") + MILL_VERSION=${MILL_VERSION%"$MILL_NATIVE_SUFFIX"} + if [ "$(expr substr $(uname -s) 1 5 2>/dev/null)" = "Linux" ]; then + if [ "$(uname -m)" = "aarch64" ]; then + ARTIFACT_SUFFIX="-native-linux-aarch64" + else + ARTIFACT_SUFFIX="-native-linux-amd64" + fi + elif [ "$(uname)" = "Darwin" ]; then + if [ "$(uname -m)" = "arm64" ]; then + ARTIFACT_SUFFIX="-native-mac-aarch64" + else + ARTIFACT_SUFFIX="-native-mac-amd64" + fi + else + echo "This native mill launcher supports only Linux and macOS." 1>&2 + exit 1 + fi +esac + +MILL="${MILL_DOWNLOAD_PATH}/${FULL_MILL_VERSION}" + +try_to_use_system_mill() { + if [ "$(uname)" != "Linux" ]; then + return 0 + fi + + MILL_IN_PATH="$(command -v mill || true)" + + if [ -z "${MILL_IN_PATH}" ]; then + return 0 + fi + + SYSTEM_MILL_FIRST_TWO_BYTES=$(head --bytes=2 "${MILL_IN_PATH}") + if [ "${SYSTEM_MILL_FIRST_TWO_BYTES}" = "#!" ]; then + # MILL_IN_PATH is (very likely) a shell script and not the mill + # executable, ignore it. + return 0 + fi + + SYSTEM_MILL_PATH=$(readlink -e "${MILL_IN_PATH}") + SYSTEM_MILL_SIZE=$(stat --format=%s "${SYSTEM_MILL_PATH}") + SYSTEM_MILL_MTIME=$(stat --format=%y "${SYSTEM_MILL_PATH}") + + if [ ! -d "${MILL_USER_CACHE_DIR}" ]; then + mkdir -p "${MILL_USER_CACHE_DIR}" + fi + + SYSTEM_MILL_INFO_FILE="${MILL_USER_CACHE_DIR}/system-mill-info" + if [ -f "${SYSTEM_MILL_INFO_FILE}" ]; then + parseSystemMillInfo() { + LINE_NUMBER="${1}" + # Select the line number of the SYSTEM_MILL_INFO_FILE, cut the + # variable definition in that line in two halves and return + # the value, and finally remove the quotes. + sed -n "${LINE_NUMBER}p" "${SYSTEM_MILL_INFO_FILE}" |\ + cut -d= -f2 |\ + sed 's/"\(.*\)"/\1/' + } + + CACHED_SYSTEM_MILL_PATH=$(parseSystemMillInfo 1) + CACHED_SYSTEM_MILL_VERSION=$(parseSystemMillInfo 2) + CACHED_SYSTEM_MILL_SIZE=$(parseSystemMillInfo 3) + CACHED_SYSTEM_MILL_MTIME=$(parseSystemMillInfo 4) + + if [ "${SYSTEM_MILL_PATH}" = "${CACHED_SYSTEM_MILL_PATH}" ] \ + && [ "${SYSTEM_MILL_SIZE}" = "${CACHED_SYSTEM_MILL_SIZE}" ] \ + && [ "${SYSTEM_MILL_MTIME}" = "${CACHED_SYSTEM_MILL_MTIME}" ]; then + if [ "${CACHED_SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then + MILL="${SYSTEM_MILL_PATH}" + return 0 + else + return 0 + fi + fi + fi + + SYSTEM_MILL_VERSION=$(${SYSTEM_MILL_PATH} --version | head -n1 | sed -n 's/^Mill.*version \(.*\)/\1/p') + + cat < "${SYSTEM_MILL_INFO_FILE}" +CACHED_SYSTEM_MILL_PATH="${SYSTEM_MILL_PATH}" +CACHED_SYSTEM_MILL_VERSION="${SYSTEM_MILL_VERSION}" +CACHED_SYSTEM_MILL_SIZE="${SYSTEM_MILL_SIZE}" +CACHED_SYSTEM_MILL_MTIME="${SYSTEM_MILL_MTIME}" +EOF + + if [ "${SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then + MILL="${SYSTEM_MILL_PATH}" + fi +} +try_to_use_system_mill + +# If not already downloaded, download it +if [ ! -s "${MILL}" ] ; then + + # support old non-XDG download dir + MILL_OLD_DOWNLOAD_PATH="${HOME}/.mill/download" + OLD_MILL="${MILL_OLD_DOWNLOAD_PATH}/${MILL_VERSION}" + if [ -x "${OLD_MILL}" ] ; then + MILL="${OLD_MILL}" + else + case $MILL_VERSION in + 0.0.* | 0.1.* | 0.2.* | 0.3.* | 0.4.* ) + DOWNLOAD_SUFFIX="" + DOWNLOAD_FROM_MAVEN=0 + ;; + 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.0-M* ) + DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=0 + ;; + *) + DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=1 + ;; + esac + + DOWNLOAD_FILE=$(mktemp mill.XXXXXX) + + if [ "$DOWNLOAD_FROM_MAVEN" = "1" ] ; then + DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist${ARTIFACT_SUFFIX}/${MILL_VERSION}/mill-dist${ARTIFACT_SUFFIX}-${MILL_VERSION}.jar" + else + MILL_VERSION_TAG=$(echo "$MILL_VERSION" | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') + DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${DOWNLOAD_SUFFIX}" + unset MILL_VERSION_TAG + fi + + # TODO: handle command not found + echo "Downloading mill ${MILL_VERSION} from ${DOWNLOAD_URL} ..." 1>&2 + ${CURL_CMD} -f -L -o "${DOWNLOAD_FILE}" "${DOWNLOAD_URL}" + chmod +x "${DOWNLOAD_FILE}" + mkdir -p "${MILL_DOWNLOAD_PATH}" + mv "${DOWNLOAD_FILE}" "${MILL}" + + unset DOWNLOAD_FILE + unset DOWNLOAD_SUFFIX + fi +fi + +if [ -z "$MILL_MAIN_CLI" ] ; then + MILL_MAIN_CLI="${0}" +fi + +MILL_FIRST_ARG="" +if [ "$1" = "--bsp" ] || [ "$1" = "-i" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then + # Need to preserve the first position of those listed options + MILL_FIRST_ARG=$1 + shift +fi + +unset MILL_DOWNLOAD_PATH +unset MILL_OLD_DOWNLOAD_PATH +unset OLD_MILL +unset MILL_VERSION +unset MILL_REPO_URL + +# We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes +# shellcheck disable=SC2086 +exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" \ No newline at end of file diff --git a/example/scalalib/web/11-todo-http4s-scalasql/resources/webapp/index.css b/example/scalalib/web/11-todo-http4s-scalasql/resources/webapp/index.css new file mode 100644 index 00000000000..07ef4a160e3 --- /dev/null +++ b/example/scalalib/web/11-todo-http4s-scalasql/resources/webapp/index.css @@ -0,0 +1,378 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; + font-weight: 300; +} + +button, +input[type="checkbox"] { + outline: none; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + outline: none; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +label[for='toggle-all'] { + display: none; +} + +.toggle-all { + position: absolute; + top: -55px; + left: -12px; + width: 60px; + height: 34px; + text-align: center; + border: none; /* Mobile Safari */ +} + +.toggle-all:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 13px 17px 12px 17px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li .toggle:checked:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li label { + white-space: pre-line; + word-break: break-all; + padding: 15px 60px 15px 15px; + margin-left: 45px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a.selected, +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; + position: relative; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } + + .toggle-all { + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + -webkit-appearance: none; + appearance: none; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/example/scalalib/web/11-todo-http4s-scalasql/resources/webapp/main.js b/example/scalalib/web/11-todo-http4s-scalasql/resources/webapp/main.js new file mode 100644 index 00000000000..4a7617f50c2 --- /dev/null +++ b/example/scalalib/web/11-todo-http4s-scalasql/resources/webapp/main.js @@ -0,0 +1,70 @@ +var state = "all"; + +var todoApp = document.getElementsByClassName("todoapp")[0]; +function postFetchUpdate(url){ + fetch(url, { + method: "POST", + }) + .then(function(response){ return response.text()}) + .then(function (text) { + todoApp.innerHTML = text; + initListeners() + }) +} + +function bindEvent(cls, url, endState){ + + document.getElementsByClassName(cls)[0].addEventListener( + "mousedown", + function(evt){ + postFetchUpdate(url) + if (endState) state = endState + } + ); +} + +function bindIndexedEvent(cls, func){ + Array.from(document.getElementsByClassName(cls)).forEach( function(elem) { + elem.addEventListener( + "mousedown", + function(evt){ + postFetchUpdate(func(elem.getAttribute("data-todo-index"))) + } + ) + }); +} + +function initListeners(){ + bindIndexedEvent( + "destroy", + function(index){return "/delete/" + state + "/" + index} + ); + bindIndexedEvent( + "toggle", + function(index){return "/toggle/" + state + "/" + index} + ); + bindEvent("toggle-all", "/toggle-all/" + state); + bindEvent("todo-all", "/list/all", "all"); + bindEvent("todo-active", "/list/active", "active"); + bindEvent("todo-completed", "/list/completed", "completed"); + bindEvent("clear-completed", "/clear-completed/" + state); + var newTodoInput = document.getElementsByClassName("new-todo")[0]; + newTodoInput.addEventListener( + "keydown", + function(evt){ + if (evt.keyCode === 13) { + fetch("/add/" + state, { + method: "POST", + body: newTodoInput.value + }) + .then(function(response){ return response.text()}) + .then(function (text) { + newTodoInput.value = ""; + todoApp.innerHTML = text; + initListeners() + }) + } + } + ); +} +initListeners() \ No newline at end of file diff --git a/example/scalalib/web/11-todo-http4s-scalasql/src/WebApp.scala b/example/scalalib/web/11-todo-http4s-scalasql/src/WebApp.scala new file mode 100644 index 00000000000..e33a420ee64 --- /dev/null +++ b/example/scalalib/web/11-todo-http4s-scalasql/src/WebApp.scala @@ -0,0 +1,255 @@ +package webapp + +import scala.concurrent.duration.Duration +import scalatags.Text.all._ +import scalatags.Text.tags2 +import cats.effect._ +import cats.syntax.all._ +import com.comcast.ip4s._ +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.ember.server._ +import org.http4s.scalatags._ +import org.http4s.server.staticcontent._ +import io.circe._ +import io.circe.generic.semiauto._ +import scalasql.H2Dialect._ +import scalasql.core.DbClient +import scalasql.{Sc, Table} +import webapp.WebApp.DB.Todos +import cats.effect.unsafe.implicits.global + +object WebApp extends IOApp.Simple { + case class Todo(checked: Boolean, text: String) + + object DB { + + // Table Definition and its corresponding ORM mapping + case class Todos[T[_]]( + id: T[Int], + checked: T[Boolean], + text: T[String] + ) + + object Todos extends Table[Todos] + + def getDatabaseClient: DbClient.DataSource = { + // The example H2 database comes from the library `com.h2database:h2:2.2.224` + val dataSource = new org.h2.jdbcx.JdbcDataSource + dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1") + + new scalasql.DbClient.DataSource( + dataSource, + config = new scalasql.Config {} + ) + } + + def initializeSchema(h2Client: scalasql.DbClient.DataSource): Unit = { + h2Client.transaction { db => + db.updateRaw( + """ + CREATE TABLE IF NOT EXISTS todos ( + id INT AUTO_INCREMENT PRIMARY KEY, + checked BOOLEAN NOT NULL, + text VARCHAR NOT NULL + ); + """ + ) + println("Schema initialized (if not exists).") // Optional logging + } + } + + } + + lazy val dbClient: DbClient.DataSource = DB.getDatabaseClient + DB.initializeSchema(dbClient) + + def run = mkService.toResource.flatMap { service => + EmberServerBuilder + .default[IO] + .withHttpApp(service) + .withPort(port"8084") + .withShutdownTimeout(Duration.Zero) + .build + }.useForever + + def mkService = + IO.ref(Seq(Todo(true, "Get started with http4s"), Todo(false, "Profit!"))).map { todosRef => + def apiRoutes = HttpRoutes.of[IO] { + case POST -> Root / "list" / state => { + for { + todos <- fetchTodos + response <- Ok(renderBody(state, todos)) + } yield response + } + + case request @ POST -> Root / "add" / state => { + for { + text <- request.as[String] + _ <- IO.delay { + dbClient.transaction { db => + db.run(Todos.insert.batched(_.checked, _.text)( + (false, text) + )) + } + } + todos <- fetchTodos + response <- Ok(renderBody(state, todos)) + } yield response + } + + case POST -> Root / "delete" / state / IntVar(index) => { + for { + _ <- IO.delay { + dbClient.transaction { db => + db.run(Todos.delete(_.id === index)) + } + } + todos <- fetchTodos + response <- Ok(renderBody(state, todos)) + } yield response + } + + case POST -> Root / "toggle" / state / IntVar(index) => { + for { + _ <- IO.delay { + dbClient.transaction { db => + val current = db.run(Todos.select.filter(_.id === index)).map(_.checked).head + db.updateRaw( + s"UPDATE todos SET checked = ${!current} WHERE id = $index" + ) + } + } + todos <- fetchTodos + response <- Ok(renderBody(state, todos)) + } yield response + } + + case POST -> Root / "clear-completed" / state => { + for { + _ <- IO.blocking { + dbClient.transaction { db => + db.run(Todos.delete(_.checked === true)) + } + } + todos <- fetchTodos + response <- Ok(renderBody(state, todos)) + } yield response + } + + case POST -> Root / "toggle-all" / state => { + for { + updatedRows <- IO.blocking { + dbClient.transaction { db => + db.updateRaw("UPDATE todos SET checked = NOT checked") + } + } + _ <- IO.println("Updated rows" + updatedRows) + todos <- fetchTodos + response <- Ok(renderBody(state, todos)) + } yield response + } + + case GET -> Root => Ok(index) + } + + def fetchTodos: cats.effect.IO[Seq[webapp.WebApp.Todo]] = { + IO.blocking { + dbClient.transaction { db => + db.run(Todos.select).sortBy(_.id) + }.map(r => Todo(r.checked, r.text)) + } + } + + def renderBody(state: String, todos: Seq[Todo]) = { + IO { + val filteredTodos = state match { + case "all" => todos.zipWithIndex + case "active" => todos.zipWithIndex.filter(!_._1.checked) + case "completed" => todos.zipWithIndex.filter(_._1.checked) + } + div( + header( + cls := "header", + h1("todos"), + input(cls := "new-todo", placeholder := "What needs to be done?", autofocus := "") + ), + tags2.section( + cls := "main", + input( + id := "toggle-all", + cls := "toggle-all", + `type` := "checkbox", + if (todos.filter(_.checked).size != 0) checked else () + ), + label(`for` := "toggle-all", "Mark all as complete"), + ul( + cls := "todo-list", + for ((todo, index) <- filteredTodos) yield li( + if (todo.checked) cls := "completed" else (), + div( + cls := "view", + input( + cls := "toggle", + `type` := "checkbox", + if (todo.checked) checked else (), + data("todo-index") := index + ), + label(todo.text), + button(cls := "destroy", data("todo-index") := index) + ), + input(cls := "edit", value := todo.text) + ) + ) + ), + footer( + cls := "footer", + span(cls := "todo-count", strong(todos.filter(!_.checked).size), " items left"), + ul( + cls := "filters", + li(cls := "todo-all", a(if (state == "all") cls := "selected" else (), "All")), + li( + cls := "todo-active", + a(if (state == "active") cls := "selected" else (), "Active") + ), + li( + cls := "todo-completed", + a(if (state == "completed") cls := "selected" else (), "Completed") + ) + ), + button(cls := "clear-completed", "Clear completed") + ) + ) + } + } + + def index = renderBody("all", fetchTodos.unsafeRunSync()).map { renderedBody => + doctype("html")( + html( + lang := "en", + head( + meta(charset := "utf-8"), + meta(name := "viewport", content := "width=device-width, initial-scale=1"), + tags2.title("Template • TodoMVC"), + link(rel := "stylesheet", href := "/static/index.css") + ), + body( + tags2.section(cls := "todoapp", renderedBody), + footer( + cls := "info", + p("Double-click to edit a todo"), + p("Created by ", a(href := "http://todomvc.com", "Li Haoyi")), + p("Part of ", a(href := "http://todomvc.com", "TodoMVC")) + ), + script(src := "/static/main.js") + ) + ) + ) + } + + def staticRoutes = resourceServiceBuilder[IO]("webapp").withPathPrefix("static").toRoutes + + (apiRoutes <+> staticRoutes).orNotFound + } + +} diff --git a/example/scalalib/web/11-todo-http4s-scalasql/test/src/WebAppTests.scala b/example/scalalib/web/11-todo-http4s-scalasql/test/src/WebAppTests.scala new file mode 100644 index 00000000000..73b8b7705cf --- /dev/null +++ b/example/scalalib/web/11-todo-http4s-scalasql/test/src/WebAppTests.scala @@ -0,0 +1,84 @@ +package webapp + +import utest._ +import cats.effect._ +import cats.effect.testing.utest.EffectTestSuite +import org.http4s.client._ +import utest._ +import cats.effect._ +import cats.effect.testing.utest.EffectTestSuite +import org.http4s._ +import org.http4s.implicits._ +import org.http4s.client._ +import org.http4s.client.dsl.io._ +import org.http4s.dsl.io._ +import org.http4s.headers._ +import org.typelevel.ci.CIString + +object WebAppTests extends EffectTestSuite[IO] { + + val mkClient = WebApp.mkService.map(Client.fromHttpApp[IO]) + + val tests = Tests { + test("simpleRequest") - { + for { + client <- mkClient + page <- client.expect[String]("/") + } yield assert(page.contains("What needs to be done?")) + } + + test("POST /add/all adds a todo") { + for { + client <- mkClient + req = Request[IO](method = Method.POST, uri = uri"/add/all") + .withEntity("Learn Scala") + res <- client.expect[String](req) + } yield assert(res.contains("Learn Scala")) + } + + test("POST /list/all returns the current list") { + for { + client <- mkClient + _ <- client.status(Request[IO]( + method = Method.POST, + uri = uri"/add/all" + ).withEntity("Walk dog")) + res <- client.expect[String](Request[IO](method = Method.POST, uri = uri"/list/all")) + } yield assert(res.contains("Walk dog")) + } + + test("POST /toggle/all toggles all todos") { + for { + client <- mkClient + _ <- client.status(Request[IO]( + method = Method.POST, + uri = uri"/add/all" + ).withEntity("Clean room")) + _ <- client.status(Request[IO](method = Method.POST, uri = uri"/toggle-all/all")) + res <- client.expect[String](Request[IO](method = Method.POST, uri = uri"/list/all")) + } yield assert(res.contains("checked")) + } + + test("POST /delete/all/{id} deletes a todo") { + for { + client <- mkClient + _ <- client.expect[String]( + Request[IO](method = Method.POST, uri = uri"/add/all").withEntity("Temp Task") + ) + // Note: `Temp Task` is the fourth task created in this test suite, hence we delete task with index 4 + _ <- client.status(Request[IO](method = Method.POST, uri = uri"/delete/all/4")) + res <- client.expect[String](Request[IO](method = Method.POST, uri = uri"/list/all")) + } yield assert(!res.contains("Temp Task")) + } + + test("POST /clear-completed/all removes completed todos") { + for { + client <- mkClient + res <- client.expect[String](Request[IO](method = Method.POST, uri = uri"/list/all")) + _ = assert(res.contains("Learn Scala")) + _ <- client.status(Request[IO](method = Method.POST, uri = uri"/clear-completed/all")) + res1 <- client.expect[String](Request[IO](method = Method.POST, uri = uri"/list/all")) + } yield assert(!res1.contains("Learn Scala")) + } + } +} diff --git a/website/docs/modules/ROOT/pages/scalalib/web-examples.adoc b/website/docs/modules/ROOT/pages/scalalib/web-examples.adoc index 84885a60076..ebce04dc55b 100644 --- a/website/docs/modules/ROOT/pages/scalalib/web-examples.adoc +++ b/website/docs/modules/ROOT/pages/scalalib/web-examples.adoc @@ -45,4 +45,10 @@ include::partial$example/scalalib/web/8-cross-platform-version-publishing.adoc[] include::partial$example/scalalib/web/9-wasm.adoc[] +== TodoMVC Scalasql Web App +include::partial$example/scalalib/web/10-todo-webapp-cask-scalasql.adoc[] + +== TodoMVC Http4s Scalasql Web App + +include::partial$example/scalalib/web/11-todo-http4s-scalasql.adoc[]