Browse Source

Everywhere: Implement HTMX todo page

master
Riyyi 1 month ago
parent
commit
6b93045514
  1. 11
      Package.resolved
  2. 8
      Package.swift
  3. 4
      Public/css/style.css
  4. 0
      Public/js/site.js
  5. 10
      README.org
  6. 82
      Sources/App/Controllers/API/TodoAPIController.swift
  7. 71
      Sources/App/Controllers/TodoController.swift
  8. 8
      Sources/App/DTOs/TodoDTO.swift
  9. 24
      Sources/App/Extensions/Elementary.swift
  10. 35
      Sources/App/Views/Components/TodosFormComponent.swift
  11. 48
      Sources/App/Views/Components/TodosTableComponent.swift
  12. 27
      Sources/App/Views/Shared/MainLayout.swift
  13. 5
      Sources/App/Views/Shared/NavMenu.swift
  14. 24
      Sources/App/routes.swift
  15. 6
      Tests/AppTests/AppTests.swift
  16. 10
      requests

11
Package.resolved

@ -1,5 +1,5 @@
{ {
"originHash" : "928a4b649897cc0b7c73d0862d9400289c0503386d6f44998334e327c5931856", "originHash" : "d39a0a797e191acb95e23a306e38d51e93c56adbbcfd76f4476a7deecf94537b",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@ -37,6 +37,15 @@
"version" : "0.4.1" "version" : "0.4.1"
} }
}, },
{
"identity" : "elementary-htmx",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sliemeobn/elementary-htmx.git",
"state" : {
"revision" : "3e09519a605be410e8332627b260cebaf74bb9fa",
"version" : "0.3.0"
}
},
{ {
"identity" : "fluent", "identity" : "fluent",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

8
Package.swift

@ -17,14 +17,16 @@ let package = Package(
.package(url: "https://github.com/apple/swift-nio.git", from: "2.76.1"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.76.1"),
// //
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.0"), .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.0"),
//
.package(url: "https://github.com/sliemeobn/elementary-htmx.git", from: "0.3.0"),
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "App", name: "App",
dependencies: [ dependencies: [
// .product(name: "ElementaryHTMX", package: "elementary-htmx"), .product(name: "ElementaryHTMX", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"), .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXWS", package: "elementary-htmx"), .product(name: "ElementaryHTMXWS", package: "elementary-htmx"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"), .product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"),
.product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"),

4
Public/css/style.css

@ -0,0 +1,4 @@
tr.htmx-swapping td {
opacity: 0;
transition: opacity 0.5s ease-out;
}

0
Public/js/site.js

10
README.org

@ -0,0 +1,10 @@
* Website
Website, written in Swift 6.
* Libraries
- [[https://github.com/vapor/vapor][Vapor]] HTTP web framework
- [[https://github.com/vapor/fluent.git][Fluent]] ORM
- [[https://github.com/sliemeobn/elementary][Elementary]] HTML templating
- [[https://github.com/bigskysoftware/htmx][htmx]] font-end JS

82
Sources/App/Controllers/API/TodoAPIController.swift

@ -0,0 +1,82 @@
import Fluent
import Vapor
struct TodoAPIController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos")
todos.get(use: index)
todos.post(use: create)
todos.group(":id") { todo in
todo.get(use: show)
todo.put(use: update)
todo.delete(use: delete)
}
// todos.delete(":id", use: delete)
}
@Sendable
func index(req: Request) async throws -> [TodoDTO] {
try await Todo.query(on: req.db).all().map { $0.toDTO() }
}
@Sendable
func show(req: Request) async throws -> TodoDTO {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
return todo.toDTO()
}
@Sendable
func create(req: Request) async throws -> TodoDTO {
let todo = try req.content.decode(TodoDTO.self).toModel()
try await todo.save(on: req.db)
return todo.toDTO()
}
@Sendable
func update(req: Request) async throws -> TodoDTO {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
let updatedTodo = try req.content.decode(Todo.self)
todo.title = updatedTodo.title
try await todo.save(on: req.db)
return todo.toDTO()
}
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
try await todo.delete(on: req.db)
return .noContent
}
// -------------------------------------
private func hexToUUID(hex: String) -> UUID? {
var uuid: String = hex.replacingOccurrences(of: "-", with: "")
guard uuid.count == 32 else { return nil }
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 8))
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 12 + 1))
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 16 + 2))
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 20 + 3))
return UUID(uuidString: uuid)
}
}

71
Sources/App/Controllers/TodoController.swift

@ -1,60 +1,63 @@
import Elementary
import ElementaryHTMX
import ElementaryHTMXSSE
import ElementaryHTMXWS
import Fluent import Fluent
import Vapor import Vapor
import VaporElementary
struct TodoController: RouteCollection { struct TodoController: RouteCollection {
func boot(routes: RoutesBuilder) throws { func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos") routes.group("todos") { todos in
todos.get(use: index) todos.get(use: index)
todos.post(use: create) todos.post(use: create)
todos.group(":id") { todo in todos.group(":id") { todo in
todo.get(use: show)
todo.put(use: update)
todo.delete(use: delete) todo.delete(use: delete)
} }
// todos.delete(":todoID", use: delete) }
} }
@Sendable @Sendable
func index(req: Request) async throws -> [TodoDTO] { func index(req: Request) async throws -> HTMLResponse {
try await Todo.query(on: req.db).all().map { $0.toDTO() } let todos = try await todos(db: req.db)
return HTMLResponse {
MainLayout(title: "Todos") {
TodosTableComponent(name: "todos", todos: todos)
TodosFormComponent(name: "todos-form", target: "todos")
}
}
} }
@Sendable @Sendable
func show(req: Request) async throws -> TodoDTO { func create(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), do {
let todo = try await Todo.find(uuid, on: req.db) else { try TodoDTO.validate(content: req)
throw Abort(.notFound) }
catch let error as ValidationsError {
return HTMLResponse {
TodosFormComponent(name: "todos-form", target: "todos", errors: ["title": error.description])
} }
return todo.toDTO()
} }
@Sendable
func create(req: Request) async throws -> TodoDTO {
let todo = try req.content.decode(TodoDTO.self).toModel() let todo = try req.content.decode(TodoDTO.self).toModel()
try await todo.save(on: req.db) try await todo.save(on: req.db)
return todo.toDTO() let todos = try await todos(db: req.db)
}
@Sendable return HTMLResponse {
func update(req: Request) async throws -> TodoDTO { // Return the empty form
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), TodosFormComponent(name: "todos-form", target: "todos")
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
let updatedTodo = try req.content.decode(Todo.self) // Also update the todos table
todo.title = updatedTodo.title TodosTableComponent(name: "todos", todos: todos, refresh: true) // TODO: Put component names inside variables
try await todo.save(on: req.db) }
return todo.toDTO()
} }
@Sendable @Sendable
func delete(req: Request) async throws -> HTTPStatus { func delete(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else { let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound) throw Abort(.notFound)
@ -62,11 +65,16 @@ struct TodoController: RouteCollection {
try await todo.delete(on: req.db) try await todo.delete(on: req.db)
return .noContent return HTMLResponse {} // TODO: Return 204 No Content
} }
// ------------------------------------- // -------------------------------------
@Sendable
private func todos(db: any Database) async throws -> [TodoDTO] {
try await Todo.query(on: db).all().map { $0.toDTO() }
}
private func hexToUUID(hex: String) -> UUID? { private func hexToUUID(hex: String) -> UUID? {
var uuid: String = hex.replacingOccurrences(of: "-", with: "") var uuid: String = hex.replacingOccurrences(of: "-", with: "")
@ -79,4 +87,5 @@ struct TodoController: RouteCollection {
return UUID(uuidString: uuid) return UUID(uuidString: uuid)
} }
} }

8
Sources/App/DTOs/TodoDTO.swift

@ -15,3 +15,11 @@ struct TodoDTO: Content {
return model return model
} }
} }
extension TodoDTO: Validatable {
static func validations(_ validations: inout Validations) {
validations.add(
"title", as: String.self, is: !.empty, required: true,
customFailureDescription: "Title is required")
}
}

24
Sources/App/Extensions/Elementary.swift

@ -0,0 +1,24 @@
import Elementary
import ElementaryHTMX
// -----------------------------------------
// Elementary
public extension HTMLAttribute {
static func empty() -> HTMLAttribute {
return HTMLAttribute(name: "", value: "")
}
}
// -----------------------------------------
// ElementaryHTMX
public extension HTMLAttribute.hx {
static func swap(_ value: HTMLAttributeValue.HTMX.ModifiedSwapTarget, _ spec: String? = nil) -> HTMLAttribute {
if let spec {
.init(name: "hx-swap", value: "\(value.rawValue) \(spec)")
} else {
.init(name: "hx-swap", value: value.rawValue)
}
}
}

35
Sources/App/Views/Components/TodosFormComponent.swift

@ -0,0 +1,35 @@
import Elementary
import Fluent
struct TodosFormComponent: HTML {
var name: String
var target: String
var errors: [String: String] = [:]
// -------------------------------------
var content: some HTML {
div(.id("cdiv_" + name)) {
form(
.id("\(name)-form"),
.hx.post("/\(target)"), .hx.target("#cdiv_" + name), .hx.swap(.outerHTML)
) {
div(.class("row")) {
div(.class("col-11")) {
input(
.class("form-control"), .type(.text), .id("\(name)-title"),
.name("title"), .placeholder("Title")) // .required
}
div(.class("col-1")) {
button(.class("btn btn-success"), .type(.submit)) { "Add" }
}
}
if let error = errors["title"] {
div(.class("form-text text-danger px-2")) { "\(error)." }
}
}
}
}
}

48
Sources/App/Views/Components/TodosTableComponent.swift

@ -0,0 +1,48 @@
import Elementary
import ElementaryHTMX
import Fluent
struct TodosTableComponent: HTML {
var name: String
var todos: [TodoDTO]
var refresh: Bool = false
// -------------------------------------
var content: some HTML {
div(.id("cdiv_" + name), refresh ? .hx.swapOOB(.outerHTML) : .empty()) {
table(.class("table")) {
thead {
tr {
th { "#" }
th { "ID" }
th { "Title" }
th { "Modifier" }
}
}
tbody(
.hx.confirm("Are you sure?"), .hx.target("closest tr"),
.hx.swap(.outerHTML, "swap:0.5s")
) {
for (index, todo) in todos.enumerated() {
tr {
td { "\(index)" }
td { todo.id?.uuidString ?? "" }
td { todo.title ?? "" }
td {
if let id = todo.id {
button(
.class("btn btn-danger"),
.hx.delete("/\(name)/\(id.uuidString)")
) { "Delete" }
}
}
}
}
}
}
}
}
}

27
Sources/App/Views/Shared/MainLayout.swift

@ -1,11 +1,12 @@
import Elementary import Elementary
// https://www.srihash.org/
extension MainLayout: Sendable where Body: Sendable {} extension MainLayout: Sendable where Body: Sendable {}
struct MainLayout<Body: HTML>: HTMLDocument { struct MainLayout<Body: HTML>: HTMLDocument {
var title: String var title: String
@HTMLBuilder var pageContent: Body // This var name can't be changed! @HTMLBuilder var pageContent: Body // This var name can't be changed!
// https://www.srihash.org/
var head: some HTML { var head: some HTML {
meta(.charset(.utf8)) meta(.charset(.utf8))
meta(.name(.viewport), .content("width=device-width, initial-scale=1.0")) meta(.name(.viewport), .content("width=device-width, initial-scale=1.0"))
@ -18,7 +19,10 @@ struct MainLayout<Body: HTML>: HTMLDocument {
.href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"), .href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"),
.integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"), .integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"),
.crossorigin(.anonymous)) .crossorigin(.anonymous))
link(.rel(.stylesheet), .href("/style.css")) link(.rel(.stylesheet), .href("/css/style.css"))
// ---------------------------------
// Style
style { style {
""" """
@ -38,10 +42,10 @@ struct MainLayout<Body: HTML>: HTMLDocument {
} }
// --------------------------------- // ---------------------------------
// Body // Content
main { main {
div(.class("cotainer mt-4")) { div(.class("container mt-4")) {
div(.class("content px-4 pb-4")) { div(.class("content px-4 pb-4")) {
pageContent pageContent
} }
@ -57,6 +61,21 @@ struct MainLayout<Body: HTML>: HTMLDocument {
.integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"), .integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"),
.crossorigin(.anonymous) .crossorigin(.anonymous)
) {} ) {}
script(
.src("https://cdnjs.cloudflare.com/ajax/libs/htmx/2.0.3/htmx.min.js"),
.integrity("sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq"),
.crossorigin(.anonymous)
) {}
script(
.src("https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2/sse.min.js"),
.integrity("sha384-yhS+rWHB2hwrHEg86hWiQV7XL6u+PH9X+3BlmS2+CNBaGYU8Nd7RZ2rZ9DWXgTdr"),
.crossorigin(.anonymous)
) {}
script(
.src("https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.1/ws.min.js"),
.integrity("sha384-yhWpPsq2os1hEnx1I8cH7Ius6rclwTm3G2fhXDLF6Pzv7UnSsXY7BAj4fB6PIgSz"),
.crossorigin(.anonymous)
) {}
script(.src("/js/site.js")) {} script(.src("/js/site.js")) {}
} }

5
Sources/App/Views/Shared/NavMenu.swift

@ -15,7 +15,10 @@ struct NavMenu: HTML {
div(.class("collapse navbar-collapse"), .id("navbarNav")) { div(.class("collapse navbar-collapse"), .id("navbarNav")) {
ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) { ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) {
li(.class("nav-item")) { li(.class("nav-item")) {
a(.class("nav-link active"), .href("#")) { "Home" } a(.class("nav-link active"), .href("/")) { "Home" }
}
li(.class("nav-item")) {
a(.class("nav-link active"), .href("/todos")) { "Todos" }
} }
li(.class("nav-item")) { li(.class("nav-item")) {
a(.class("nav-link"), .href("#")) { "About" } a(.class("nav-link"), .href("#")) { "About" }

24
Sources/App/routes.swift

@ -6,27 +6,23 @@ import VaporElementary
func routes(_ app: Application) throws { func routes(_ app: Application) throws {
app.routes.caseInsensitive = true app.routes.caseInsensitive = true
app.get { req async throws in app.get() { req async throws in
let todo = Todo(title: "Test Todo")
try await todo.save(on: req.db)
return "It works!"
}
app.get("hello") { req async -> String in
"Hello, world!"
}
app.get("test") { _ in
HTMLResponse { HTMLResponse {
MainLayout(title: "Test123") { MainLayout(title: "Homepage") {
IndexPage() IndexPage()
} }
} }
} }
app.get("hello") { req async -> String in
"Hello, world!"
}
try app.register(collection: TodoController()) try app.register(collection: TodoController())
try app.group("api") { api in
try api.register(collection: TodoAPIController())
}
} }
/* /*

6
Tests/AppTests/AppTests.swift

@ -36,7 +36,7 @@ struct AppTests {
let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")] let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")]
try await sampleTodos.create(on: app.db) try await sampleTodos.create(on: app.db)
try await app.test(.GET, "todos", afterResponse: { res async throws in try await app.test(.GET, "api/todos", afterResponse: { res async throws in
#expect(res.status == .ok) #expect(res.status == .ok)
#expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} ) #expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} )
}) })
@ -48,7 +48,7 @@ struct AppTests {
let newDTO = TodoDTO(id: nil, title: "test") let newDTO = TodoDTO(id: nil, title: "test")
try await withApp { app in try await withApp { app in
try await app.test(.POST, "todos", beforeRequest: { req in try await app.test(.POST, "api/todos", beforeRequest: { req in
try req.content.encode(newDTO) try req.content.encode(newDTO)
}, afterResponse: { res async throws in }, afterResponse: { res async throws in
#expect(res.status == .ok) #expect(res.status == .ok)
@ -66,7 +66,7 @@ struct AppTests {
try await withApp { app in try await withApp { app in
try await testTodos.create(on: app.db) try await testTodos.create(on: app.db)
try await app.test(.DELETE, "todos/\(testTodos[0].requireID())", afterResponse: { res async throws in try await app.test(.DELETE, "api/todos/\(testTodos[0].requireID())", afterResponse: { res async throws in
#expect(res.status == .noContent) #expect(res.status == .noContent)
let model = try await Todo.find(testTodos[0].id, on: app.db) let model = try await Todo.find(testTodos[0].id, on: app.db)
#expect(model == nil) #expect(model == nil)

10
requests

@ -14,7 +14,7 @@
# ------------------------------------------ # ------------------------------------------
GET http://localhost:8080/todos GET http://localhost:8080/api/todos
-> jq-set-var :id .[0].id -> jq-set-var :id .[0].id
Accept: application/json Accept: application/json
@ -22,12 +22,12 @@ Accept: application/json
# ------------------------------------------ # ------------------------------------------
GET http://localhost:8080/todos/:id GET http://localhost:8080/api/todos/:id
Accept: application/json Accept: application/json
# ------------------------------------------ # ------------------------------------------
POST http://localhost:8080/todos POST http://localhost:8080/api/todos
-> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read)))) -> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read))))
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
@ -38,7 +38,7 @@ Content-Type: application/json
# ------------------------------------------ # ------------------------------------------
PUT http://localhost:8080/todos/:id PUT http://localhost:8080/api/todos/:id
-> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read)))) -> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read))))
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
@ -55,7 +55,7 @@ Content-Type: application/json
# INSERT(INSERT(INSERT(INSERT(HEX(id), 9, 0, '-'), 14, 0, '-'), 19, 0, '-'), 24, 0, '-') AS id, # INSERT(INSERT(INSERT(INSERT(HEX(id), 9, 0, '-'), 14, 0, '-'), 19, 0, '-'), 24, 0, '-') AS id,
# title FROM riyyi.todos; # title FROM riyyi.todos;
DELETE http://localhost:8080/todos/:id DELETE http://localhost:8080/api/todos/:id
Accept: application/json Accept: application/json
# ------------------------------------------ # ------------------------------------------

Loading…
Cancel
Save