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[]